From fba034fae239da11094f1b1e1b06e90e211e9550 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 06:51:56 +0000 Subject: [PATCH 1/7] merge: apply ppy/osu commit #37838 - fix popup dialogs not appearing when OverlayActivationMode is wrong Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/b0503980-c181-4806-a9b6-a2b891be69df Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../UserInterface/TestSceneDialogOverlay.cs | 50 ++++++++++++++++++- osu.Game/Overlays/Dialog/PopupDialog.cs | 3 +- osu.Game/Overlays/DialogOverlay.cs | 13 ++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index f2313022ec4b..97f3dd455d2d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -7,6 +7,7 @@ using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; @@ -16,10 +17,12 @@ namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public partial class TestSceneDialogOverlay : OsuTestScene + public partial class TestSceneDialogOverlay : OsuTestScene, IOverlayManager { private DialogOverlay overlay; + private readonly Bindable overlayActivationMode = new Bindable(OverlayActivation.All); + [SetUpSteps] public void SetUpSteps() { @@ -99,7 +102,8 @@ public void TestTooMuchText() { Icon = FontAwesome.Regular.TrashAlt, HeaderText = @"Confirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion of", - BodyText = @"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ", + BodyText = + @"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ", Buttons = new PopupDialogButton[] { new PopupDialogOkButton @@ -116,6 +120,36 @@ public void TestTooMuchText() })); } + [Test] + public void TestPushWhileOverlayActivationDisabled() + { + PopupDialog dialog = null; + + AddStep("set activation mode disabled", () => overlayActivationMode.Value = OverlayActivation.Disabled); + + AddStep("push dialog", () => + { + overlay.Push(dialog = new TestPopupDialog + { + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton { Text = @"OK" }, + }, + }); + }); + + AddUntilStep("overlay not visible", () => overlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("set activation mode enabled", () => overlayActivationMode.Value = OverlayActivation.All); + + AddUntilStep("overlay visible", () => overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("dialog displayed", () => dialog.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("set activation mode disabled", () => overlayActivationMode.Value = OverlayActivation.Disabled); + + AddUntilStep("dialog hidden", () => dialog.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("dialog dismissed", () => overlay.CurrentDialog, () => Is.Null); + } + [Test] public void TestPushBeforeLoad() { @@ -194,5 +228,17 @@ public void TestDismissBeforePushViaButtonPress() private partial class TestPopupDialog : PopupDialog { } + + public IBindable OverlayActivationMode => overlayActivationMode; + + public IDisposable RegisterBlockingOverlay(OverlayContainer overlayContainer) => throw new NotImplementedException(); + + public void ShowBlockingOverlay(OverlayContainer overlay) + { + } + + public void HideBlockingOverlay(OverlayContainer overlay) + { + } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 3e10cec6c705..021345a4c68e 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using System.Numerics; @@ -238,7 +237,7 @@ protected PopupDialog() } [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) + private void load(AudioManager audio) { flashSample = audio.Samples.Get(@"UI/default-select-disabled"); } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 057210c90f23..9c2b6b913693 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Framework.Logging; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Overlays.Dialog; @@ -28,8 +29,14 @@ public partial class DialogOverlay : OsuFocusedOverlayContainer, IDialogOverlay public PopupDialog CurrentDialog { get; private set; } - public override bool IsPresent => Scheduler.HasPendingTasks - || dialogContainer.Children.Count > 0; + public override bool IsPresent => (Scheduler.HasPendingTasks || dialogContainer.Children.Count > 0) + // The following line ensures that dialogs are not presented while the dialog overlay + // cannot be displayed. This is due to the `Schedule` usage inside `Push()`. + // + // Without this, a dialog pushed during disabled overlay activation mode would be presented, + // but immediately dismissed without ever being seen by the user (see + // https://github.com/ppy/osu/blob/ce5e54c9d27b17d460d99e774de502f9480fb710/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs#L131-L136). + && OverlayActivationMode.Value == OverlayActivation.All; [CanBeNull] private IDisposable duckOperation; @@ -77,6 +84,7 @@ public void Push(PopupDialog dialog) return; } + Logger.Log($"{nameof(DialogOverlay)}: Showing dialog {dialog}"); dialogContainer.Add(dialog); Show(); @@ -98,6 +106,7 @@ void dismiss() // Handle the case where the dialog is the currently displayed dialog. // In this scenario, the overlay itself should also be hidden. Hide(); + Logger.Log($"{nameof(DialogOverlay)}: Dismissing dialog {dialog}"); CurrentDialog = null; } } From d028b58180144c397f0d85fc8cb0469f56d2a340 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 06:59:33 +0000 Subject: [PATCH 2/7] perf: eliminate LINQ allocations in osu ruleset hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OsuFramedReplayInputHandler.IsImportant: frame.Actions.Any() → frame.Actions.Count > 0 (called every replay frame; removes enumerator alloc) - SpeedEvaluator.EvaluateDifficultyOf: mods.OfType().Any() → inline for-loop with early break (called per note in difficulty calc) - RhythmEvaluator.EvaluateDifficultyOf: replaced double-scan pattern (FirstOrDefault + IndexOf, two O(n) passes) with a single indexed for-loop that finds and mutates the entry in one pass - OsuDifficultyCalculator.CreateDifficultyAttributes: replaced three separate beatmap.HitObjects.Count(h => h is T) calls (three full enumerations) with a single switch-based loop; also replaced four skills.OfType().Single/SingleOrDefault() calls with a single typed loop - OsuDifficultyCalculator.CreateSkills: mods.Any(h => h is OsuModFlashlight) → inline for-loop with early break - OsuAutoGenerator.addHitObjectClickFrames: previousActions.Any() → .Count > 0; frame.Actions.SequenceEqual(previousActions) → new static ActionsEqual helper (avoids IEnumerable allocation on every frame) Removed now-unused 'using System.Linq' from all changed files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 18 +++-- .../Difficulty/Evaluators/SpeedEvaluator.cs | 9 ++- .../Difficulty/OsuDifficultyCalculator.cs | 65 ++++++++++++++++--- .../Replays/OsuAutoGenerator.cs | 15 ++++- .../Replays/OsuFramedReplayInputHandler.cs | 3 +- 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 9349083951e1..c60ad0452f92 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; @@ -116,12 +115,21 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) if (previousIsland.DeltaCount == island.DeltaCount) effectiveRatio *= 0.5; - var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); + int islandCountIndex = -1; + (Island Island, int Count) islandCount = default; - if (islandCount != default) + for (int k = 0; k < islandCounts.Count; k++) { - int countIndex = islandCounts.IndexOf(islandCount); + if (islandCounts[k].Island.Equals(island)) + { + islandCountIndex = k; + islandCount = islandCounts[k]; + break; + } + } + if (islandCountIndex >= 0) + { // only add island to island counts if they're going one after another if (previousIsland.Equals(island)) islandCount.Count++; @@ -130,7 +138,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); - islandCounts[countIndex] = (islandCount.Island, islandCount.Count); + islandCounts[islandCountIndex] = (islandCount.Island, islandCount.Count); } else { diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index a58c1d36853e..7f2107b6075e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; @@ -63,7 +62,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); - if (mods.OfType().Any()) + bool hasAutopilot = false; + for (int i = 0; i < mods.Count; i++) + { + if (mods[i] is OsuModAutopilot) { hasAutopilot = true; break; } + } + + if (hasAutopilot) distanceBonus = 0; // Base difficulty with all bonuses diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 504fddbb711a..33f3d24961de 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -51,10 +50,36 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; - var aim = skills.OfType().Single(a => a.IncludeSliders); - var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); - var speed = skills.OfType().Single(); - var flashlight = skills.OfType().SingleOrDefault(); + Aim? aim = null; + Aim? aimWithoutSliders = null; + Speed? speed = null; + Flashlight? flashlight = null; + + for (int i = 0; i < skills.Length; i++) + { + switch (skills[i]) + { + case Aim a when a.IncludeSliders: + aim = a; + break; + + case Aim a: + aimWithoutSliders = a; + break; + + case Speed s: + speed = s; + break; + + case Flashlight f: + flashlight = f; + break; + } + } + + ArgumentNullException.ThrowIfNull(aim); + ArgumentNullException.ThrowIfNull(aimWithoutSliders); + ArgumentNullException.ThrowIfNull(speed); double speedNotes = speed.RelevantNoteCount(); @@ -74,9 +99,25 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); - int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); - int sliderCount = beatmap.HitObjects.Count(h => h is Slider); - int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); + int hitCircleCount = 0, sliderCount = 0, spinnerCount = 0; + + for (int i = 0; i < beatmap.HitObjects.Count; i++) + { + switch (beatmap.HitObjects[i]) + { + case HitCircle: + hitCircleCount++; + break; + + case Slider: + sliderCount++; + break; + + case Spinner: + spinnerCount++; + break; + } + } int totalHits = beatmap.HitObjects.Count; @@ -186,7 +227,13 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo new Speed(mods) }; - if (mods.Any(h => h is OsuModFlashlight)) + bool hasFlashlight = false; + for (int i = 0; i < mods.Length; i++) + { + if (mods[i] is OsuModFlashlight) { hasFlashlight = true; break; } + } + + if (hasFlashlight) skills.Add(new Flashlight(mods)); return skills.ToArray(); diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 529523baf035..a8374db65caa 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Numerics; using osu.Framework.Graphics; using osu.Framework.Utils; @@ -308,7 +307,7 @@ private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, floa var previousActions = previousFrame.Actions; // If a button is already held, then we simply alternate - if (previousActions.Any()) + if (previousActions.Count > 0) { // Force alternation if we have the same button. Otherwise we can just keep the naturally to us assigned button. if (previousActions.Contains(action)) @@ -330,7 +329,7 @@ private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, floa var frame = (OsuReplayFrame)Frames[j]; // Don't affect frames which stop pressing a button! - if (j < Frames.Count - 1 || frame.Actions.SequenceEqual(previousActions)) + if (j < Frames.Count - 1 || ActionsEqual(frame.Actions, previousActions)) { frame.Actions.Clear(); frame.Actions.Add(action); @@ -395,6 +394,16 @@ private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, floa AddFrameToReplay(endFrame); } + private static bool ActionsEqual(List a, List b) + { + if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) + { + if (a[i] != b[i]) return false; + } + return true; + } + #endregion private class OsuKeyUpReplayFrame : OsuReplayFrame diff --git a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs index ea36ecc3992f..d5ef4b0d37c4 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; using osu.Framework.Input.StateChanges; using osu.Framework.Utils; using osu.Game.Replays; @@ -17,7 +16,7 @@ public OsuFramedReplayInputHandler(Replay replay) { } - protected override bool IsImportant(OsuReplayFrame frame) => frame.Actions.Any(); + protected override bool IsImportant(OsuReplayFrame frame) => frame.Actions.Count > 0; protected override void CollectReplayInputs(List inputs) { From 54b20a385229cce7e2c0a9e90d1d75125bf93db6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 07:12:05 +0000 Subject: [PATCH 3/7] Android: cache Enabled and ADPF GameThread refs to reduce hot-path overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AndroidMouseHandler/StylusHandler/KeyboardHandler: add volatile bool cachedEnabled field (mirror of Enabled.Value via BindValueChanged), matching the existing cachedSensitivity/cachedPressureThreshold/ TreatAsTouch pattern. The OS dispatch thread now reads a direct volatile field instead of traversing the Bindable property chain on every MotionEvent/KeyEvent. The volatile write-then-read guarantees the same cross-thread visibility that a BindValueChanged-to-Update-thread would provide for the enable/disable path. - OsuGameAndroid: add adpfDrawThread/adpfUpdateThread/adpfInputThread GameThread? fields, assigned once when each ADPF hint session is created (on the respective game thread). The three per-frame FrameCompleted callbacks (onDrawFrameCompleted, onUpdateFrameCompleted, onInputFrameCompleted) now read adpf*Thread?.Clock.ElapsedFrameTime instead of Host?.DrawThread?.Clock.ElapsedFrameTime, removing two levels of nullable chain traversal per call. At 120 Hz × 3 threads these callbacks fire ~360 times/second, so eliminating the Host and *Thread property accesses is a measurable reduction in per-frame bookkeeping cost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Android/Input/AndroidKeyboardHandler.cs | 9 ++++++++- osu.Android/Input/AndroidMouseHandler.cs | 9 ++++++++- osu.Android/Input/AndroidStylusHandler.cs | 8 +++++++- osu.Android/OsuGameAndroid.cs | 18 +++++++++++++++--- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/osu.Android/Input/AndroidKeyboardHandler.cs b/osu.Android/Input/AndroidKeyboardHandler.cs index 31d3279a00c6..bfa8718ded09 100644 --- a/osu.Android/Input/AndroidKeyboardHandler.cs +++ b/osu.Android/Input/AndroidKeyboardHandler.cs @@ -54,6 +54,11 @@ public class AndroidKeyboardHandler : InputHandler { Keycode.Period, Key.Period }, { Keycode.Slash, Key.Slash }, }.ToFrozenDictionary(); + // Cached enabled state. Enabled.Value reads through the Bindable property + // chain; a volatile field gives the OS dispatch thread a direct read that is also + // cross-thread correct. + private volatile bool cachedEnabled = true; + public AndroidKeyboardHandler() { Enabled.Default = true; @@ -65,13 +70,15 @@ public override bool Initialize(GameHost host) if (!base.Initialize(host)) return false; + Enabled.BindValueChanged(v => cachedEnabled = v.NewValue, true); + return true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HandleKeyEvent(KeyEvent e) { - if (!Enabled.Value) return false; + if (!cachedEnabled) return false; if (e.KeyCode == Keycode.Back || e.KeyCode == Keycode.Home || e.KeyCode == Keycode.Menu || e.KeyCode == Keycode.VolumeUp || e.KeyCode == Keycode.VolumeDown || e.KeyCode == Keycode.VolumeMute || diff --git a/osu.Android/Input/AndroidMouseHandler.cs b/osu.Android/Input/AndroidMouseHandler.cs index 0a91e1277c55..04e3a32cf802 100644 --- a/osu.Android/Input/AndroidMouseHandler.cs +++ b/osu.Android/Input/AndroidMouseHandler.cs @@ -54,6 +54,12 @@ public class AndroidMouseHandler : InputHandler, IHasCursorSensitivity // Avoids a BindableDouble read per MotionEvent. private volatile float cachedSensitivity = 1f; + // Cached enabled state. Enabled.Value reads through the Bindable property + // chain; a volatile field gives the OS dispatch thread a direct read that is also + // cross-thread correct (changes written on the Update thread are immediately + // visible here via the volatile acquire/release semantics). + private volatile bool cachedEnabled = true; + public AndroidMouseHandler() { Enabled.Default = true; @@ -66,6 +72,7 @@ public override bool Initialize(GameHost host) return false; Sensitivity.BindValueChanged(v => cachedSensitivity = (float)v.NewValue, true); + Enabled.BindValueChanged(v => cachedEnabled = v.NewValue, true); return true; } @@ -73,7 +80,7 @@ public override bool Initialize(GameHost host) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HandleMotionEvent(MotionEvent e) { - if (!Enabled.Value) return false; + if (!cachedEnabled) return false; if (e.ActionMasked == MotionEventActions.Scroll) { diff --git a/osu.Android/Input/AndroidStylusHandler.cs b/osu.Android/Input/AndroidStylusHandler.cs index 786914a720d0..569ce3320756 100644 --- a/osu.Android/Input/AndroidStylusHandler.cs +++ b/osu.Android/Input/AndroidStylusHandler.cs @@ -91,6 +91,11 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler private bool useRotation; private float cachedPressureThreshold; + // Cached enabled state. Enabled.Value reads through the Bindable property + // chain; a volatile field gives the OS dispatch thread a direct read that is also + // cross-thread correct. + private volatile bool cachedEnabled = true; + // Cached tablet bounds — updated whenever `tablet.Value` is reassigned. Avoids // three bindable reads + property accesses per historical pointer sample in the // hot path. A local-field comparison is a single un-locked memory read. @@ -178,6 +183,7 @@ public override bool Initialize(GameHost host) Rotation.BindValueChanged(_ => updateCachedTransform()); PressureThreshold.BindValueChanged(v => cachedPressureThreshold = v.NewValue, true); + Enabled.BindValueChanged(v => cachedEnabled = v.NewValue, true); // Force one initial cache population so `areaWidth` / `outWidth` are non-zero // before the very first MotionEvent arrives (BindValueChanged above only fires @@ -285,7 +291,7 @@ private void updateCachedTransform() [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HandleMotionEvent(MotionEvent e) { - if (!Enabled.Value) return false; + if (!cachedEnabled) return false; // Cache ActionMasked once: each `e.ActionMasked` access is a JNI call into // MotionEvent#getActionMasked. On a busy stylus drag the previous code did diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index d05c183a1d5e..5ff54c2a36eb 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -179,6 +179,13 @@ public partial class OsuGameAndroid : OsuGame private IntPtr adpfDrawSession; private IntPtr adpfUpdateSession; + // Cached GameThread references stored when ADPF sessions are created. Avoids walking + // Host?.DrawThread? / Host?.UpdateThread? / Host?.InputThread? on every frame callback + // (3 levels of nullable dereference at 120 Hz × 3 threads = 360 null checks/s → 0). + private osu.Framework.Threading.GameThread? adpfDrawThread; + private osu.Framework.Threading.GameThread? adpfUpdateThread; + private osu.Framework.Threading.GameThread? adpfInputThread; + // Input thread ADPF session. The input thread may poll at ~100 kHz (one cycle ≈ 10 µs), // which is far finer than the ADPF reporting granularity. Instead of calling // reportActualWorkDuration once per poll (which would flood the ADPF API), we accumulate @@ -603,6 +610,9 @@ protected override void LoadComplete() // Silent no-op on API < 35 (resolved via dlsym). OboeAudioBridge.nADPFSetPreferPowerEfficiency(adpfDrawSession, 0); Logger.Log($"[osu!] ADPF session created for Draw thread (target={targetNs / 1_000_000.0:F2}ms)", LoggingTarget.Performance); + // Cache the thread reference now (we're already on the Draw thread) + // so per-frame callbacks avoid the Host?.DrawThread? nullable chain. + adpfDrawThread = Host!.DrawThread; // Subscribe per-frame reporting now that the session handle is valid. // FrameCompleted fires on the Draw thread itself, so reading // Host.DrawThread.Clock.ElapsedFrameTime is thread-safe. @@ -622,6 +632,7 @@ protected override void LoadComplete() { OboeAudioBridge.nADPFSetPreferPowerEfficiency(adpfUpdateSession, 0); Logger.Log($"[osu!] ADPF session created for Update thread (target={targetNs / 1_000_000.0:F2}ms)", LoggingTarget.Performance); + adpfUpdateThread = Host!.UpdateThread; Host!.UpdateThread!.FrameCompleted += onUpdateFrameCompleted; } } @@ -643,6 +654,7 @@ protected override void LoadComplete() { OboeAudioBridge.nADPFSetPreferPowerEfficiency(adpfInputSession, 0); Logger.Log($"[osu!] ADPF session created for Input thread (target={targetNs / 1_000_000.0:F2}ms)", LoggingTarget.Performance); + adpfInputThread = Host!.InputThread; Host!.InputThread!.FrameCompleted += onInputFrameCompleted; } } @@ -1607,7 +1619,7 @@ private void onDrawFrameCompleted() try { - double elapsedMs = Host?.DrawThread?.Clock.ElapsedFrameTime ?? 0; + double elapsedMs = adpfDrawThread?.Clock.ElapsedFrameTime ?? 0; if (elapsedMs > 0) OboeAudioBridge.nADPFReportActualDuration(adpfDrawSession, (long)(elapsedMs * 1_000_000.0)); } @@ -1624,7 +1636,7 @@ private void onUpdateFrameCompleted() try { - double elapsedMs = Host?.UpdateThread?.Clock.ElapsedFrameTime ?? 0; + double elapsedMs = adpfUpdateThread?.Clock.ElapsedFrameTime ?? 0; if (elapsedMs > 0) OboeAudioBridge.nADPFReportActualDuration(adpfUpdateSession, (long)(elapsedMs * 1_000_000.0)); } @@ -1644,7 +1656,7 @@ private void onInputFrameCompleted() try { - double elapsedMs = Host?.InputThread?.Clock.ElapsedFrameTime ?? 0; + double elapsedMs = adpfInputThread?.Clock.ElapsedFrameTime ?? 0; if (elapsedMs <= 0) return; inputAdpfAccumulatedMs += elapsedMs; From 3ed53baf51ef15dd9277f4c6d55d57030e67c965 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 07:20:14 +0000 Subject: [PATCH 4/7] perf: reduce allocations in beatmap conversion, playable bounds, and HUD components - BeatmapConverter: replace OrderBy().ToList() with in-place List.Sort() to avoid creating an intermediate IOrderedEnumerable and a second list. - IBeatmap.CalculatePlayableBounds: collapse three separate LINQ enumerations (Any, Max, First) into a single foreach loop. Reduces from O(3n) to O(n) and eliminates two LINQ iterator allocations on every call site. - PausableSkinnableSound.Length: previously evaluated DrawableSamples twice (once for Any(), once for Max()), creating two LINQ chains. Replaced with a single foreach that accumulates the maximum sample length. - JudgementCounter, JudgementCounterDisplay, ArgonJudgementCounter, ArgonJudgementCounterDisplay: replaced Types.First() with direct Types[0] array indexing and removed the now-unused 'using System.Linq' imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game/Beatmaps/BeatmapConverter.cs | 4 +++- osu.Game/Beatmaps/IBeatmap.cs | 21 ++++++++++++++----- .../HUD/JudgementCounter/JudgementCounter.cs | 3 +-- .../JudgementCounterDisplay.cs | 3 +-- .../Components/ArgonJudgementCounter.cs | 3 +-- .../ArgonJudgementCounterDisplay.cs | 2 +- osu.Game/Skinning/PausableSkinnableSound.cs | 15 +++++++++++-- 7 files changed, 36 insertions(+), 15 deletions(-) diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index 2d520a8975ed..cb703f7507bf 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -70,7 +70,9 @@ protected virtual Beatmap ConvertBeatmap(IBeatmap original, CancellationToken beatmap.BeatmapInfo = original.BeatmapInfo; beatmap.ControlPointInfo = original.ControlPointInfo; - beatmap.HitObjects = convertHitObjects(original.HitObjects, original, cancellationToken).OrderBy(s => s.StartTime).ToList(); + var hitObjects = convertHitObjects(original.HitObjects, original, cancellationToken); + hitObjects.Sort((a, b) => a.StartTime.CompareTo(b.StartTime)); + beatmap.HitObjects = hitObjects; beatmap.Breaks = original.Breaks; beatmap.AudioLeadIn = original.AudioLeadIn; beatmap.StackLeniency = original.StackLeniency; diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 7880457a69d6..475cfc5836d0 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -196,13 +196,24 @@ public static double CalculatePlayableLength(IEnumerable objects) /// public static (double start, double end) CalculatePlayableBounds(IEnumerable objects) { - if (!objects.Any()) - return (0, 0); + double firstObjectTime = double.MaxValue; + double lastObjectTime = double.MinValue; + bool any = false; - double lastObjectTime = objects.Max(o => o.GetEndTime()); - double firstObjectTime = objects.First().StartTime; + foreach (var obj in objects) + { + any = true; + + if (obj.StartTime < firstObjectTime) + firstObjectTime = obj.StartTime; + + double endTime = obj.GetEndTime(); + + if (endTime > lastObjectTime) + lastObjectTime = endTime; + } - return (firstObjectTime, lastObjectTime); + return any ? (firstObjectTime, lastObjectTime) : (0, 0); } #endregion diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs index 77c03069be9b..c957868e5085 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -52,7 +51,7 @@ private void load(OsuColour colours) } }; - var result = Result.Types.First(); + var result = Result.Types[0]; Colour = result.IsBasic() ? colours.ForHitResult(result) : !result.IsBonus() ? colours.PurpleLight : colours.PurpleLighter; } diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs index 822653d4f3ac..8f605e19da95 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -89,7 +88,7 @@ bool shouldShow(int index, JudgementCounter counter) if (index == 0 && !ShowMaxJudgement.Value) return false; - var hitResult = counter.Result.Types.First(); + var hitResult = counter.Result.Types[0]; switch (Mode.Value) { diff --git a/osu.Game/Skinning/Components/ArgonJudgementCounter.cs b/osu.Game/Skinning/Components/ArgonJudgementCounter.cs index 0e6a01d0afa9..2bbb6cebf63e 100644 --- a/osu.Game/Skinning/Components/ArgonJudgementCounter.cs +++ b/osu.Game/Skinning/Components/ArgonJudgementCounter.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -57,7 +56,7 @@ protected override void LoadComplete() updateWireframe(); }, true); - var result = Result.Types.First(); + var result = Result.Types[0]; textComponent.LabelColour.Value = getJudgementColor(result); textComponent.ShowLabel.BindValueChanged(v => textComponent.TextColour.Value = !v.NewValue ? getJudgementColor(result) : Colour4.White, true); } diff --git a/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs b/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs index 1a53a48ab6c0..c3edde523b41 100644 --- a/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs +++ b/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs @@ -117,7 +117,7 @@ private bool shouldBeVisible(int index, ArgonJudgementCounter counter) if (index == 0 && !ShowMaxJudgement.Value) return false; - var hitResult = counter.Result.Types.First(); + var hitResult = counter.Result.Types[0]; switch (Mode.Value) { diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index e752160984fc..665d4b005c9e 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -3,8 +3,8 @@ #nullable disable +using System; using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +15,18 @@ namespace osu.Game.Skinning { public partial class PausableSkinnableSound : SkinnableSound { - public double Length => !DrawableSamples.Any() ? 0 : DrawableSamples.Max(sample => sample.Length); + public double Length + { + get + { + double max = 0; + + foreach (var sample in DrawableSamples) + max = Math.Max(max, sample.Length); + + return max; + } + } public bool RequestedPlaying { get; private set; } From d2d87d27d0126c21af8b82dcae1a87e1a5c399c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 07:45:58 +0000 Subject: [PATCH 5/7] fix: CI errors (CS8619/IDE1006) + merge ppy #37795 (no invite/duel in ranked rooms) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/27570109-9bbd-46a9-bb06-b8e5b4034215 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Difficulty/Evaluators/RhythmEvaluator.cs | 2 +- osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs | 4 ++-- .../Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs | 3 +++ osu.Game/Users/UserPanel.cs | 5 +++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index c60ad0452f92..64bd5d539e27 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -138,7 +138,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); - islandCounts[islandCountIndex] = (islandCount.Island, islandCount.Count); + islandCounts[islandCountIndex] = (islandCount.Island!, islandCount.Count); } else { diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index a8374db65caa..b38659962564 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -329,7 +329,7 @@ private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, floa var frame = (OsuReplayFrame)Frames[j]; // Don't affect frames which stop pressing a button! - if (j < Frames.Count - 1 || ActionsEqual(frame.Actions, previousActions)) + if (j < Frames.Count - 1 || actionsEqual(frame.Actions, previousActions)) { frame.Actions.Clear(); frame.Actions.Add(action); @@ -394,7 +394,7 @@ private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, floa AddFrameToReplay(endFrame); } - private static bool ActionsEqual(List a, List b) + private static bool actionsEqual(List a, List b) { if (a.Count != b.Count) return false; for (int i = 0; i < a.Count; i++) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index aef8ee51d544..ba30f08f304c 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -89,6 +89,9 @@ public void LeaveQueue() public void IssueDuel(MatchmakingPool pool, int userId) { + if (client.Room?.Settings.MatchType.IsMatchmakingType() == true) + return; + lastDuelUser = userId; lastDuelPool = pool; diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index ce03ebc66f91..3923b3cb14f2 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -23,6 +23,7 @@ using osu.Game.Online.Chat; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; using osu.Game.Screens; @@ -210,9 +211,9 @@ public MenuItem[] ContextMenuItems return items.ToArray(); bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; - bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; + bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true && multiplayerClient?.Room?.Settings.MatchType.IsMatchmakingType() != true; bool isUserBlocked() => api.LocalUserState.Blocks.Any(b => b.TargetID == User.OnlineID); - bool canDuelUser() => isUserOnline() && queueController?.SelectedPool.Value != null; + bool canDuelUser() => isUserOnline() && queueController?.SelectedPool.Value != null && multiplayerClient?.Room?.Settings.MatchType.IsMatchmakingType() != true; } } From 64a45cc5488e4b00711955e8f9659d72e4379045 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 08:31:18 +0000 Subject: [PATCH 6/7] style: fix 13 InspectCode warnings (blank lines + statement formatting) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/c05408ff-2261-4192-be17-427dbac05881 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Difficulty/Evaluators/SpeedEvaluator.cs | 7 ++++++- .../Difficulty/OsuDifficultyCalculator.cs | 7 ++++++- osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 7f2107b6075e..8c8cef1aa07c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -63,9 +63,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnly distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); bool hasAutopilot = false; + for (int i = 0; i < mods.Count; i++) { - if (mods[i] is OsuModAutopilot) { hasAutopilot = true; break; } + if (mods[i] is OsuModAutopilot) + { + hasAutopilot = true; + break; + } } if (hasAutopilot) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 33f3d24961de..4afbcd8477e9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -228,9 +228,14 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo }; bool hasFlashlight = false; + for (int i = 0; i < mods.Length; i++) { - if (mods[i] is OsuModFlashlight) { hasFlashlight = true; break; } + if (mods[i] is OsuModFlashlight) + { + hasFlashlight = true; + break; + } } if (hasFlashlight) diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index b38659962564..3eac4994a0c0 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -397,10 +397,12 @@ private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, floa private static bool actionsEqual(List a, List b) { if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) { if (a[i] != b[i]) return false; } + return true; } From 9695c215391362b87b28aa785461cd5215f3ecf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 11:44:11 +0000 Subject: [PATCH 7/7] merge: apply ppy #37839 - move configuration migrations to OsuGame Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/53e9d70f-a74f-443c-9861-5f785a91df94 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../NonVisual/CustomDataDirectoryTest.cs | 24 ++++----- .../DevelopmentOsuConfigManager.cs | 4 +- osu.Game/Configuration/OsuConfigManager.cs | 46 +---------------- osu.Game/OsuGame.cs | 51 +++++++++++++++++++ osu.Game/OsuGameBase.cs | 6 +-- .../Maintenance/MigrationRunScreen.cs | 2 +- osu.Game/Updater/UpdateManager.cs | 4 -- 7 files changed, 70 insertions(+), 67 deletions(-) diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index f556a2a1cde6..96f211811542 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -126,7 +126,7 @@ public void TestMigration() Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); - osu.Migrate(customPath); + osu.MigrateUserData(customPath); Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); @@ -183,16 +183,16 @@ public void TestMigrationBetweenTwoTargets() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); - Assert.DoesNotThrow(() => osu.Migrate(customPath2)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath2)); Assert.That(File.Exists(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME))); // some files may have been left behind for whatever reason, but that's not what we're testing here. cleanupPath(customPath); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); } finally @@ -212,8 +212,8 @@ public void TestMigrationToSameTargetFails() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); - Assert.Throws(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); + Assert.Throws(() => osu.MigrateUserData(customPath)); } finally { @@ -238,14 +238,14 @@ public void TestMigrationFailsOnExistingData() string originalDirectory = storage.GetFullPath("."); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); Directory.CreateDirectory(customPath2); File.WriteAllText(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME), "I am a text"); // Fails because file already exists. - Assert.Throws(() => osu.Migrate(customPath2)); + Assert.Throws(() => osu.MigrateUserData(customPath2)); osuStorage?.ChangeDataPath(customPath2); @@ -269,7 +269,7 @@ public void TestMigrationToNestedTargetFails() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); string subFolder = Path.Combine(customPath, "sub"); @@ -278,7 +278,7 @@ public void TestMigrationToNestedTargetFails() Directory.CreateDirectory(subFolder); - Assert.Throws(() => osu.Migrate(subFolder)); + Assert.Throws(() => osu.MigrateUserData(subFolder)); } finally { @@ -297,7 +297,7 @@ public void TestMigrationToSeeminglyNestedTarget() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); string seeminglySubFolder = customPath + "sub"; @@ -306,7 +306,7 @@ public void TestMigrationToSeeminglyNestedTarget() Directory.CreateDirectory(seeminglySubFolder); - osu.Migrate(seeminglySubFolder); + osu.MigrateUserData(seeminglySubFolder); } finally { diff --git a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs index 0d8fe90423d8..c979ebf453ee 100644 --- a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs +++ b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs @@ -9,8 +9,8 @@ public class DevelopmentOsuConfigManager : OsuConfigManager { protected override string Filename => base.Filename.Replace(".ini", ".dev.ini"); - public DevelopmentOsuConfigManager(Storage storage, GameHost? host = null) - : base(storage, host) + public DevelopmentOsuConfigManager(Storage storage) + : base(storage) { } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index a329829087e9..121e0b674883 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -2,15 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Input.Handlers.Mouse; -using osu.Framework.Input.Handlers.Pen; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Beatmaps.Drawables.Cards; @@ -33,14 +30,9 @@ namespace osu.Game.Configuration { public class OsuConfigManager : IniConfigManager, IGameplaySettings { - private readonly GameHost? host; - - public OsuConfigManager(Storage storage, GameHost? host = null) + public OsuConfigManager(Storage storage) : base(storage) { - this.host = host; - - Migrate(); } protected override void InitialiseDefaults() @@ -334,42 +326,6 @@ protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) return false; } - public void Migrate() - { - // arrives as 2020.123.0-lazer - string rawVersion = Get(OsuSetting.Version); - - if (rawVersion.Length < 6) - return; - - string[] pieces = rawVersion.Split('.'); - - // on a fresh install or when coming from a non-release build, execution will end here. - // we don't want to run migrations in such cases. - if (!int.TryParse(pieces[0], out int year)) return; - if (!int.TryParse(pieces[1], out int monthDay)) return; - - int combined = year * 10000 + monthDay; - - if (combined < 20250214) - { - // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. - if (RuntimeInfo.IsMobile) - GetBindable(OsuSetting.UIScale).SetDefault(); - } - - if (combined < 20250428) - { - // Pen tablet sensitivity is now separated from cursor sensitivity. - // Most users will want the default to be what they already had set on cursor sensitivity so let's transfer it. - var mouseHandler = host?.AvailableInputHandlers.OfType().SingleOrDefault(); - var penHandler = host?.AvailableInputHandlers.OfType().SingleOrDefault(); - - if (penHandler != null && mouseHandler != null && penHandler.Sensitivity.IsDefault) - penHandler.Sensitivity.Value = mouseHandler.Sensitivity.Value; - } - } - public override TrackedSettings CreateTrackedSettings() { return new TrackedSettings diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 1526bb8754f1..9cde127cb7df 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -26,6 +26,8 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Input.Handlers.Pen; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -1315,6 +1317,55 @@ protected override void LoadComplete() // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. handleStartupImport(); + + applyConfigMigrations(); + + // finally, update the version stored to the configuration. + // this MUST happen after `applyConfigMigrations()` call, as it relies on comparing the previous version. + // debug / local compilations will reset to a non-release string. + LocalConfig.SetValue(OsuSetting.Version, Version); + } + + /// + /// Apply any migrations to configuration. + /// + /// + /// For database migrations, see . + /// + private void applyConfigMigrations() + { + // arrives as 2020.123.0-lazer + string rawVersion = LocalConfig.Get(OsuSetting.Version); + + if (rawVersion.Length < 6) + return; + + string[] pieces = rawVersion.Split('.'); + + // on a fresh install or when coming from a non-release build, execution will end here. + // we don't want to run migrations in such cases. + if (!int.TryParse(pieces[0], out int year)) return; + if (!int.TryParse(pieces[1], out int monthDay)) return; + + int combined = year * 10000 + monthDay; + + if (combined < 20250214) + { + // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. + if (RuntimeInfo.IsMobile) + LocalConfig.GetBindable(OsuSetting.UIScale).SetDefault(); + } + + if (combined < 20260520) + { + // Pen tablet sensitivity is now separated from cursor sensitivity. + // Most users will want the default to be what they already had set on cursor sensitivity so let's transfer it. + var mouseHandler = Host?.AvailableInputHandlers.OfType().SingleOrDefault(); + var penHandler = Host?.AvailableInputHandlers.OfType().SingleOrDefault(); + + if (penHandler != null && mouseHandler != null && penHandler.Sensitivity.IsDefault) + penHandler.Sensitivity.Value = mouseHandler.Sensitivity.Value; + } } private void handleBackButton() diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 97235b4cab6f..bb0fc9e66c31 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -682,8 +682,8 @@ public override void SetHost(GameHost host) Storage ??= host.Storage; LocalConfig ??= UseDevelopmentServer - ? new DevelopmentOsuConfigManager(Storage, host) - : new OsuConfigManager(Storage, host); + ? new DevelopmentOsuConfigManager(Storage) + : new OsuConfigManager(Storage); host.ExceptionThrown += onExceptionThrown; } @@ -731,7 +731,7 @@ public void CancelRestartOnExit() /// The path to migrate to. /// Whether migration succeeded to completion. If false, some files were left behind. /// - public bool Migrate(string path) + public bool MigrateUserData(string path) { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index 3d3cc2f77ffa..a60d141d070b 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -102,7 +102,7 @@ protected override void LoadComplete() }); } - protected virtual bool PerformMigration() => game?.Migrate(destination.FullName) != false; + protected virtual bool PerformMigration() => game?.MigrateUserData(destination.FullName) != false; public override void OnEntering(ScreenTransitionEvent e) { diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 8b5932dd950b..005989c29444 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -80,10 +80,6 @@ protected override void LoadComplete() Logger.Log(NotificationsStrings.NotOfficialBuild.ToString()); } - // debug / local compilations will reset to a non-release string. - // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). - config.SetValue(OsuSetting.Version, version); - config.BindWith(OsuSetting.ReleaseStream, releaseStream); releaseStream.BindValueChanged(_ => CheckForUpdate());