From 3f5c113394e9c07505d3bc83e95753a2dddf68ca Mon Sep 17 00:00:00 2001 From: triacontakai <31161627+triacontakai@users.noreply.github.com> Date: Thu, 7 May 2026 02:59:54 -0400 Subject: [PATCH 01/16] Fix non-default mod settings allowing for duplicate freestyle mod selection in multiplayer (#37646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a bug that allowed for selecting the same mod twice in multiplayer if the playlist entry has non-default settings for a required mod. This happens due to a strict equality mod check in the mod set compatibility check function, which only considers mods duplicates if their settings are exactly the same. Replacing with a more lenient `Type` check fixes this. Adds a regression test for this behavior Fixes https://github.com/ppy/osu/issues/37625. --------- Co-authored-by: Bartłomiej Dach --- osu.Game.Tests/Mods/ModUtilsTest.cs | 19 +++++++++++++ .../TestSceneMultiplayerMatchSubScreen.cs | 27 +++++++++++++++++++ osu.Game/Utils/ModUtils.cs | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index b05b4c00b95c..f0f08e5a31fc 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -7,8 +7,10 @@ using Moq; using NUnit.Framework; using NUnit.Framework.Legacy; +using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; @@ -33,6 +35,17 @@ public void TestModIsNotCompatibleWithItself() Assert.That(invalid, Is.EquivalentTo(new[] { mod.Object })); } + [Test] + public void TestModIsNotCompatibleWithItselfEvenIfSettingsDiffer() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + mod2.Setup(m => m.Setting).Returns(new BindableBool(true)); + + Assert.That(ModUtils.CheckCompatibleSet(new[] { mod1.Object, mod2.Object }, out var invalid), Is.False); + Assert.That(invalid, Is.EquivalentTo(new[] { mod2.Object })); + } + [Test] public void TestModIsCompatibleByItself() { @@ -397,6 +410,12 @@ public abstract class CustomMod2 : Mod, IModCompatibilitySpecification { } + public abstract class CustomMod3 : Mod, IModCompatibilitySpecification + { + [SettingSource("Setting")] + public virtual BindableBool Setting { get; } = new BindableBool(); + } + private class InvalidMultiplayerMod : Mod { public override string Name => string.Empty; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 379a589ca964..7a72178645ad 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -330,6 +330,33 @@ public void TestModSelectOverlay() AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); } + [Test] + public void TestModSelectOverlayNonDefaultSettings() + { + AddStep("add playlist item", () => + { + room.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + RequiredMods = + [ + new APIMod(new OsuModSuddenDeath { FailOnSliderTail = { Value = true } }), + ], + AllowedMods = [], + Freestyle = true + } + ]; + }); + ClickButtonWhenEnabled(); + + AddUntilStep("wait for join", () => RoomJoined); + + ClickButtonWhenEnabled(); + AddAssert("sudden death not visible", () => this.ChildrenOfType().Single().ChildrenOfType().Single(m => m.Mod is ModSuddenDeath).Visible == false); + } + [Test] public void TestChangeSettingsButtonVisibleForHost() { diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index e944b188f16b..01b0aab46b63 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -63,7 +63,7 @@ public static bool CheckCompatibleSet(IEnumerable combination, [NotNullWhen { var m = mods[j]; - if (candidate.Equals(m)) + if (candidate.GetType() == m.GetType()) { invalidMods ??= new List(); invalidMods.Add(m); From 7f385c787328ec145a158676b5dc929d52193762 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 7 May 2026 16:55:15 +0900 Subject: [PATCH 02/16] Add better support for handling disconnection at the ranked play queue screen (#37658) Until now the queue screen basically did nothing to let the user knowing they were disconnected from the server. Now the various components will correctly clear state and show a roughly competent "i'm trying to reconnect" state. https://github.com/user-attachments/assets/bff1b241-a6a2-445a-9ffa-b5682f2a3656 --- Can be tested using the following patch (hit `F7` to reconnect, with a 5 second delay to show the disconnected state too): ```diff diff --git a/osu.Game/Online/PersistentEndpointClientConnector.cs b/osu.Game/Online/PersistentEndpointClientConnector.cs index 7064906be4..ae539aba8d 100644 --- a/osu.Game/Online/PersistentEndpointClientConnector.cs +++ b/osu.Game/Online/PersistentEndpointClientConnector.cs @@ -99,6 +99,8 @@ private async Task connect() // this will also create a new cancellation token source. await disconnect(false).ConfigureAwait(false); + await Task.Delay(5000).ConfigureAwait(false); + // this token will be valid for the scope of this connection. // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. var cancellationToken = connectCancelSource.Token; diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 703444a92f..fb467472d3 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -22,6 +22,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Framework.Input.Handlers; using osu.Framework.Input.Handlers.Joystick; using osu.Framework.Input.Handlers.Midi; @@ -65,6 +66,7 @@ using osu.Game.Scoring; using osu.Game.Skinning; using osu.Game.Utils; +using osuTK.Input; using RuntimeInfo = osu.Framework.RuntimeInfo; namespace osu.Game @@ -104,7 +106,7 @@ public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncPro /// private const double global_track_volume_adjust = 0.8; - public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; + public virtual bool UseDevelopmentServer => false; public virtual EndpointConfiguration CreateEndpoints() => UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); @@ -466,6 +468,20 @@ private void addFilesWarning() } } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.F7) + { + Logger.Log("Forcing reconnect!", level: LogLevel.Important); + + ((IStatefulUserHubClient)MultiplayerClient).ServerShuttingDown(); + ((IStatefulUserHubClient)SpectatorClient).ServerShuttingDown(); + ((IStatefulUserHubClient)metadataClient).ServerShuttingDown(); + } + + return base.OnKeyDown(e); + } + private void onTrackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction) => beatmapClock.ChangeSource(beatmap.Track); protected virtual void InitialiseFonts() ``` --- .../Matchmaking/Queue/CloudVisualisation.cs | 31 ++++++----- .../Matchmaking/Queue/PoolSelector.cs | 33 +++++++++--- .../Matchmaking/Queue/ScreenQueue.cs | 51 ++++++++++++------- 3 files changed, 79 insertions(+), 36 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs index 33ed21f3db48..e13edadb5a1e 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs @@ -34,21 +34,26 @@ public APIUser[] Users set { users = value; + if (IsLoaded) + refresh(); + } + } - foreach (var u in usersContainer) - u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire(); + private void refresh() + { + foreach (var u in usersContainer) + u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire(); - LoadComponentsAsync(users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => + LoadComponentsAsync(users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => + { + if (usersContainer.Count == 0) { - if (usersContainer.Count == 0) - { - usersContainer.ScaleTo(0) - .ScaleTo(1, 5000, Easing.OutPow10); - } - - usersContainer.AddRange(avatars); - }); - } + usersContainer.ScaleTo(0) + .ScaleTo(1, 5000, Easing.OutPow10); + } + + usersContainer.AddRange(avatars); + }); } protected override void LoadComplete() @@ -64,6 +69,8 @@ protected override void LoadComplete() RelativeSizeAxes = Axes.X, }, }; + + refresh(); } public partial class MovingAvatar : MatchmakingAvatar diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs index 7995f72f1af6..8b5593eddea8 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs @@ -24,25 +24,36 @@ public partial class PoolSelector : CompositeDrawable { private const float icon_size = 34; - public readonly Bindable AvailablePools = new Bindable([]); + public readonly Bindable AvailablePools = new Bindable([]); public readonly Bindable SelectedPool = new Bindable(); private FillFlowContainer poolFlow = null!; + private LoadingSpinner loading = null!; public PoolSelector() { - AutoSizeAxes = Axes.Both; + AutoSizeAxes = Axes.X; + Height = SelectorButton.SIZE.Y + 10; } [BackgroundDependencyLoader] private void load() { - InternalChild = poolFlow = new FillFlowContainer + InternalChildren = new Drawable[] { - AutoSizeAxes = Axes.X, - Height = SelectorButton.SIZE.Y + 10, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), + poolFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + }, + loading = new LoadingSpinner(withBox: true) + { + Size = new Vector2(50), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } }; } @@ -54,6 +65,14 @@ protected override void LoadComplete() { poolFlow.Clear(); + if (pools.NewValue == null) + { + loading.Show(); + return; + } + + loading.Hide(); + foreach (var p in pools.NewValue) { poolFlow.Add(new SelectorButton(p) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs index bcf99d742b0f..9b325cd08039 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -80,7 +80,7 @@ public partial class ScreenQueue : OsuScreen private readonly IBindable currentState = new Bindable(); - private readonly Bindable availablePools = new Bindable([]); + private readonly Bindable availablePools = new Bindable(); private readonly Bindable selectedPool = new Bindable(); private readonly MatchmakingPoolType poolType; @@ -99,6 +99,8 @@ public partial class ScreenQueue : OsuScreen private GridContainer mainGrid = null!; + private IBindable isConnected = null!; + public ScreenQueue(MatchmakingPoolType poolType) { this.poolType = poolType; @@ -339,9 +341,22 @@ protected override void LoadComplete() currentState.BindValueChanged(s => SetState(s.NewValue)); selectedPool.BindTo(queue.SelectedPool); - selectedPool.BindValueChanged(onSelectedPoolChanged, true); + selectedPool.BindValueChanged(e => refreshLobbyData()); - populateAvailablePools().FireAndForget(); + isConnected = client.IsConnected.GetBoundCopy(); + isConnected.BindValueChanged(connected => Schedule(() => + { + if (connected.NewValue) + { + populateAvailablePools().FireAndForget(); + refreshLobbyData(); + } + else + { + availablePools.Value = null; + clearLobbyData(); + } + }), true); } private async Task populateAvailablePools() @@ -367,7 +382,7 @@ private void onMatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) => S { APIUser?[] users = result.GetResultSafely(); if (!cancellation.IsCancellationRequested) - Users = users.OfType().ToArray(); + cloud.Users = users.OfType().ToArray(); }), cancellation.Token); // Global (incremental) updates will not contain the user rating, so keep the one we already received from initial status data. @@ -402,15 +417,11 @@ private async Task loadRecentMatches(RankedPlayRoomState[] matches) }); } - private void onSelectedPoolChanged(ValueChangedEvent e) + private void refreshLobbyData() { - userRating = null; - ratingGraph.SetData([], null); - - resultPanelContainer.Clear(); - resultPanelContainer.LayoutDuration = 0; + clearLobbyData(); - if (e.NewValue == null) + if (selectedPool.Value == null) { client.MatchmakingLeaveLobby().FireAndForget(); return; @@ -418,10 +429,20 @@ private void onSelectedPoolChanged(ValueChangedEvent e) client.MatchmakingJoinLobbyWithParams(new MatchmakingJoinLobbyRequest { - PoolId = e.NewValue.Id + PoolId = selectedPool.Value.Id }).FireAndForget(); } + private void clearLobbyData() + { + resultPanelContainer.Clear(); + resultPanelContainer.LayoutDuration = 0; + userRating = null; + ratingGraph.SetData([], null); + + cloud.Users = Array.Empty(); + } + public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); @@ -470,11 +491,6 @@ public override bool OnExiting(ScreenExitEvent e) } } - public APIUser[] Users - { - set => cloud.Users = value; - } - public void SetState(MatchmakingScreenState newState) { mainContent.FadeInFromZero(500, Easing.OutQuint); @@ -515,6 +531,7 @@ public void SetState(MatchmakingScreenState newState) Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Width = 200, + Enabled = { BindTarget = isConnected }, SelectedPool = { BindTarget = selectedPool }, Action = () => { From 1818c1b1e6ed5de2346aedfb0d30a276d55b6f7a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 7 May 2026 17:51:19 +0900 Subject: [PATCH 03/16] Fix pause ambience loop not playing at fail screen (#37663) Matches stable. --- Addresses https://github.com/ppy/osu/discussions/37580. --- osu.Game/Screens/Play/GameplayMenuOverlay.cs | 62 +++++++++++++++++++- osu.Game/Screens/Play/PauseOverlay.cs | 60 ------------------- osu.Game/Screens/Play/Player.cs | 1 + 3 files changed, 61 insertions(+), 62 deletions(-) diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index d4c40c78ae3a..12f0937eb5a4 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,6 +15,8 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -23,6 +27,7 @@ using osuTK.Graphics; using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; +using osu.Game.Skinning; using osu.Game.Utils; namespace osu.Game.Screens.Play @@ -76,10 +81,15 @@ protected GameplayMenuOverlay() } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, GameHost? host) { Children = new Drawable[] { + pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) + { + Looping = true, + Volume = { Value = 0 } + }, new Box { RelativeSizeAxes = Axes.Both, @@ -142,6 +152,9 @@ private void load(OsuColour colours) State.ValueChanged += _ => InternalButtons.Deselect(); updateInfoText(); + + if (host != null) + windowActive.BindTo(host.IsActive); } private int retries; @@ -164,9 +177,15 @@ protected override void PopIn() { this.FadeIn(TRANSITION_DURATION, Easing.In); updateInfoText(); + + startPauseLoop(); } - protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); + protected override void PopOut() + { + this.FadeOut(TRANSITION_DURATION, Easing.In); + stopPauseLoop(); + } protected void AddButton(LocalisableString text, Color4 colour, Action? action) { @@ -283,5 +302,44 @@ protected override bool Handle(UIEvent e) return base.Handle(e); } + + #region Pause loop sound handling + + public override bool IsPresent => base.IsPresent || pauseLoop.IsPlaying; + + private SkinnableSound pauseLoop = null!; + + private readonly IBindable windowActive = new Bindable(true); + + private float targetVolume => windowActive.Value && State.Value == Visibility.Visible ? 1.0f : 0; + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Schedule required because host.IsActive doesn't seem to always run on the update thread. + windowActive.BindValueChanged(_ => Schedule(() => pauseLoop.VolumeTo(targetVolume, 1000, Easing.Out))); + } + + public void StopAllSamples() + { + if (!IsLoaded) + return; + + pauseLoop.Stop(); + } + + private void startPauseLoop() + { + pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.InQuint); + pauseLoop.Play(); + } + + private void stopPauseLoop() + { + pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); + } + + #endregion } } diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 18d17c131711..7e429d1e21fd 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -3,29 +3,17 @@ using System; using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Framework.Platform; -using osu.Game.Audio; using osu.Game.Input.Bindings; using osu.Game.Localisation; -using osu.Game.Skinning; namespace osu.Game.Screens.Play { public partial class PauseOverlay : GameplayMenuOverlay { - public override bool IsPresent => base.IsPresent || pauseLoop.IsPlaying; - public override LocalisableString Header => GameplayMenuOverlayStrings.PausedHeader; - private SkinnableSound pauseLoop = null!; - protected override Action BackAction => () => { if (Buttons.Any()) @@ -34,54 +22,6 @@ public partial class PauseOverlay : GameplayMenuOverlay OnResume?.Invoke(); }; - private readonly IBindable windowActive = new Bindable(true); - - private float targetVolume => windowActive.Value && State.Value == Visibility.Visible ? 1.0f : 0; - - [BackgroundDependencyLoader] - private void load(GameHost? host) - { - AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) - { - Looping = true, - Volume = { Value = 0 } - }); - - if (host != null) - windowActive.BindTo(host.IsActive); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - // Schedule required because host.IsActive doesn't seem to always run on the update thread. - windowActive.BindValueChanged(_ => Schedule(() => pauseLoop.VolumeTo(targetVolume, 1000, Easing.Out))); - } - - public void StopAllSamples() - { - if (!IsLoaded) - return; - - pauseLoop.Stop(); - } - - protected override void PopIn() - { - base.PopIn(); - - pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.InQuint); - pauseLoop.Play(); - } - - protected override void PopOut() - { - base.PopOut(); - - pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); - } - public override bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index ff62db7e165d..4bde0cbd552a 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -1194,6 +1194,7 @@ public override bool OnExiting(ScreenExitEvent e) // Eagerly clean these up as disposal of child components is asynchronous and may leave sounds playing beyond user expectations. failAnimationContainer?.Stop(); PauseOverlay?.StopAllSamples(); + FailOverlay?.StopAllSamples(); if (LoadedBeatmapSuccessfully && !GameplayState.HasPassed) { From 80d7543a98cba5524f2866f7782d88d60c57feea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:22:16 +0000 Subject: [PATCH 04/16] =?UTF-8?q?perf:=20LINQ=E2=86=92loop=20(BarHitErrorM?= =?UTF-8?q?eter),=20cache=20isAutoplayPlayback,=20AndroidBDSP=202-min=20sl?= =?UTF-8?q?eep,=20MMAP=20StabilizedCallback=20skip,=20OsuGame=20BDSP=20fac?= =?UTF-8?q?tory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/4160543d-58b3-40e7-a293-c38a0b6fba4f Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../AndroidBackgroundDataStoreProcessor.cs | 27 +++++++ osu.Android/Native/oboe_bridge.cpp | 74 ++++++++++++++++--- osu.Android/OsuGameAndroid.cs | 3 + osu.Game/OsuGame.cs | 8 +- .../HUD/HitErrorMeters/BarHitErrorMeter.cs | 13 +++- osu.Game/Screens/Play/ReplayPlayer.cs | 6 +- 6 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 osu.Android/AndroidBackgroundDataStoreProcessor.cs diff --git a/osu.Android/AndroidBackgroundDataStoreProcessor.cs b/osu.Android/AndroidBackgroundDataStoreProcessor.cs new file mode 100644 index 000000000000..0ea9334426c7 --- /dev/null +++ b/osu.Android/AndroidBackgroundDataStoreProcessor.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Database; + +namespace osu.Android +{ + /// + /// Android-specific that extends the sleep + /// interval during active gameplay from the default 30 s to 2 minutes. + /// + /// + /// On Android the high-performance session () + /// flips GCSettings.LatencyMode to SustainedLowLatency for the entire gameplay window, + /// which suppresses Gen-2 (major) GC collections. The background processor's sleep loop also + /// suspends during gameplay, but wakes every + /// ms to re-check the condition. Each wake-up incurs a managed thread resume + lock acquisition, + /// generating a small burst of GC-visible allocations. At 30 s those spurious wakes happen ~10× + /// per typical 5-minute play session; at 120 s they drop to ~2×, cutting the associated + /// allocation pressure and the risk of a GC stall at the worst possible moment. + /// + public class AndroidBackgroundDataStoreProcessor : BackgroundDataStoreProcessor + { + // 2-minute polling interval while gameplay is active (vs. the default 30 s). + protected override int TimeToSleepDuringGameplay => 120_000; + } +} diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 8dd658723495..2de9a6fbc39d 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -97,9 +97,28 @@ bool OboeBridge::open(int32_t sampleRate) { // MMAP provides direct access to audio hardware buffers, shaving ~1-2ms off latency. oboe::OboeExtensions::setMMapEnabled(true); - // Initialise StabilizedCallback to even out callback execution time. - // shared_ptr is used to satisfy the non-deprecated setDataCallback overload. - stabilizedCallback_ = std::make_shared(this); + // Non-owning shared_ptr for error callback — OboeBridge outlives the stream. + auto errorCb = std::shared_ptr( + std::shared_ptr(), static_cast(this)); + + // ----------------------------------------------------------------------- + // Pass 1: open with a raw 'this' callback (no StabilizedCallback). + // + // On AAudio MMAP paths (Pixel 3+, Snapdragon 8 Gen 1+, most modern + // Android), the kernel delivers audio callbacks with near-perfect timing + // via the hardware FIFO interrupt. StabilizedCallback works by sleeping + // on the callback thread to normalise jitter — on MMAP that sleep is pure + // overhead: it delays the write to the hardware ring buffer, adding latency + // without removing any real jitter. + // + // We probe by opening with the raw callback first. If MMAP is confirmed + // we keep this stream. If not, we close it and reopen with + // StabilizedCallback (see Pass 2 below) to cover the non-MMAP / OpenSL ES + // fallback path where OS scheduler jitter is real. + // ----------------------------------------------------------------------- + stabilizedCallback_.reset(); + auto rawCb = std::shared_ptr( + std::shared_ptr(), static_cast(this)); oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output) @@ -120,18 +139,50 @@ bool OboeBridge::open(int32_t sampleRate) { ->setIsContentSpatialized(true) // Prevent other apps from capturing our audio stream (competitive integrity). ->setAllowedCapturePolicy(oboe::AllowedCapturePolicy::None) - // Use shared_ptr overload (non-deprecated) for data callback. - ->setDataCallback(stabilizedCallback_) - // Non-owning shared_ptr for error callback — OboeBridge outlives the stream. - ->setErrorCallback(std::shared_ptr( - std::shared_ptr(), static_cast(this))); + ->setDataCallback(rawCb) + ->setErrorCallback(errorCb); oboe::Result result = builder.openStream(stream_); - if (result != oboe::Result::OK) { + if (result == oboe::Result::OK) { + bool mmapActive = oboe::OboeExtensions::isMMapUsed(stream_.get()); + LOGI("Oboe pass-1 open: MMAP=%s", mmapActive ? "yes" : "no"); + + if (!mmapActive) { + // --------------------------------------------------------------- + // Pass 2: MMAP unavailable — close and reopen with StabilizedCallback. + // StabilizedCallback adds a compensating sleep to normalise the + // variable latency introduced by the OS scheduler on non-MMAP paths, + // reducing buffer underruns on devices that rely on AAudio binder IPC + // or the OpenSL ES compatibility layer. + // --------------------------------------------------------------- + stream_->close(); + stream_.reset(); + + stabilizedCallback_ = std::make_shared(this); + + builder.setDataCallback(stabilizedCallback_); + result = builder.openStream(stream_); + + if (result != oboe::Result::OK) { + LOGE("AAudio + StabilizedCallback open failed (%s), falling back to unspecified API", + oboe::convertToText(result)); + { std::lock_guard eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); } + builder.setAudioApi(oboe::AudioApi::Unspecified); + builder.setSharingMode(oboe::SharingMode::Shared); + result = builder.openStream(stream_); + } + } + } else { + // AAudio exclusive failed outright — try unspecified API + shared mode. + // Always wrap with StabilizedCallback on this fallback path since we + // almost certainly won't have MMAP on an OpenSL ES device. LOGE("AAudio open failed (%s), falling back to unspecified API", oboe::convertToText(result)); { std::lock_guard eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); } + + stabilizedCallback_ = std::make_shared(this); + builder.setDataCallback(stabilizedCallback_); builder.setAudioApi(oboe::AudioApi::Unspecified); builder.setSharingMode(oboe::SharingMode::Shared); result = builder.openStream(stream_); @@ -168,14 +219,15 @@ bool OboeBridge::open(int32_t sampleRate) { tuner_ = std::make_unique(*stream_); LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, " - "bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s", + "bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s, stabilized=%s", stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES", stream_->getSampleRate(), stream_->getFramesPerBurst(), stream_->getBufferSizeInFrames(), stream_->getBufferCapacityInFrames(), stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared", - oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no"); + oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no", + stabilizedCallback_ ? "yes" : "no"); return true; } diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 796237025c1a..79d84437a2ae 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -27,6 +27,7 @@ using osu.Framework.Platform; using osu.Game; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Screens; @@ -2725,6 +2726,8 @@ private static bool isFrameworkDuplicateOfAndroidHandler(osu.Framework.Input.Han protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier(); + protected override BackgroundDataStoreProcessor CreateBackgroundDataStoreProcessor() => new AndroidBackgroundDataStoreProcessor(); + protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo(); protected override void Dispose(bool isDisposing) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index e9dc602cfbfb..07cffaee967f 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -933,6 +933,12 @@ public override Task Import(ImportTask[] imports, ImportParameters parameters = protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + /// + /// Creates the component used by this game instance. + /// Platforms may override this to return a subclass (e.g. to adjust sleep intervals during gameplay). + /// + protected virtual BackgroundDataStoreProcessor CreateBackgroundDataStoreProcessor() => new BackgroundDataStoreProcessor(); + /// /// Adjust the globally applied in every . /// Useful for changing how the game handles different aspect ratios. @@ -1254,7 +1260,7 @@ protected override void LoadComplete() loadComponentSingleFile(new DialogOverlay(), topMostOverlayContent.Add, true); loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); - loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); + loadComponentSingleFile(CreateBackgroundDataStoreProcessor(), Add); loadComponentSingleFile(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true); loadComponentSingleFile(new QueueController(), Add, true); diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index e27a7544c99a..f632c3abad99 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -410,7 +410,18 @@ protected override void OnNewJudgement(JudgementResult judgement) const double quick_fade_time = 100; // check with a bit of lenience to avoid precision error in comparison. - var old = judgementsContainer.FirstOrDefault(j => j.LifetimeEnd > Clock.CurrentTime + quick_fade_time * 1.1); + // Manual loop avoids delegate allocation from LINQ on every judgement callback. + double threshold = Clock.CurrentTime + quick_fade_time * 1.1; + Drawable? old = null; + + foreach (var j in judgementsContainer) + { + if (j.LifetimeEnd > threshold) + { + old = j; + break; + } + } if (old != null) { diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index e2ef847c2b38..1e683176821b 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -41,7 +41,10 @@ public partial class ReplayPlayer : Player, IKeyBindingHandler // score may be null if LoadedBeatmapSuccessfully is false. Score == null ? null : new UserActivity.WatchingReplay(Score.ScoreInfo); - private bool isAutoplayPlayback => GameplayState.Mods.OfType().Any(); + // Cached in PrepareReplay() — GameplayState.Mods never changes after load, so + // re-running OfType().Any() on every CheckModsAllowFailure call + // (which fires from the fail-detection path) allocates delegates for no gain. + private bool isAutoplayPlayback; private double? lastFrameTime; @@ -144,6 +147,7 @@ protected override void PrepareReplay() { DrawableRuleset?.SetReplayScore(Score); lastFrameTime = Score.Replay.Frames.LastOrDefault()?.Time; + isAutoplayPlayback = GameplayState.Mods.OfType().Any(); } protected override Score CreateScore(IBeatmap beatmap) => createScore(beatmap, Mods.Value); From 6585152189668976febfcaaa9bc0b83497585b4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:26:26 +0000 Subject: [PATCH 05/16] merge: incorporate upstream ppy/osu content (CloudVisualisation, ScreenQueue dup-handler fix, test type fix, mod utils) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/4160543d-58b3-40e7-a293-c38a0b6fba4f Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Multiplayer/TestSceneMultiplayerMatchSubScreen.cs | 2 +- .../OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs | 9 +++++---- .../Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 9af8c314b24f..7a72178645ad 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -443,7 +443,7 @@ public void TestUserModSelectUpdatesWhenNotVisible() AllowedMods = [] }))); // This would normally be done as part of the above operation with an actual server. - AddStep("disable user mods", () => MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, Array.Empty())); + AddStep("disable user mods", () => MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, Array.Empty())); AddUntilStep("flashlight mod disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); AddStep("re-enable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0]) { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs index 75bb283439b5..e13edadb5a1e 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs @@ -23,27 +23,28 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue /// public partial class CloudVisualisation : CompositeDrawable { + private APIUser[] users = []; private Container usersContainer = null!; private readonly Bindable lastSamplePlayback = new Bindable(); public APIUser[] Users { - get; + get => users; set { - field = value; + users = value; if (IsLoaded) refresh(); } - } = []; + } private void refresh() { foreach (var u in usersContainer) u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire(); - LoadComponentsAsync(Users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => + LoadComponentsAsync(users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => { if (usersContainer.Count == 0) { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs index 16f2ee1a05f8..9b325cd08039 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -339,7 +339,6 @@ protected override void LoadComplete() currentState.BindTo(queue.CurrentState); currentState.BindValueChanged(s => SetState(s.NewValue)); - client.MatchmakingLobbyStatusChanged += onMatchmakingLobbyStatusChanged; selectedPool.BindTo(queue.SelectedPool); selectedPool.BindValueChanged(e => refreshLobbyData()); From 4a571876d8863cac1f65e2930a6a2f95691bfc3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:39:18 +0000 Subject: [PATCH 06/16] perf: hot-path alloc elimination batch (ScoreProcessor, DrawableSlider, SliderInputManager, DrawableSpinner, BeatmapCarousel, OsuGameAndroid, GameplaySampleTriggerSource, DrawableHitObject, oboe_bridge) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/49b81aac-c71a-4671-b650-5a258c6b1baf Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Android/Native/oboe_bridge.cpp | 6 ++-- osu.Android/OsuGameAndroid.cs | 13 ++++--- .../Objects/Drawables/DrawableSlider.cs | 18 ++++++++-- .../Objects/Drawables/DrawableSpinner.cs | 23 ++++++++++--- .../Objects/Drawables/SliderInputManager.cs | 25 ++++++++++++-- .../Objects/Drawables/DrawableHitObject.cs | 5 +-- osu.Game/Rulesets/Scoring/ScoreProcessor.cs | 9 ++++- .../UI/GameplaySampleTriggerSource.cs | 34 +++++++++---------- .../Select/BeatmapCarouselFilterSorting.cs | 33 +++++++++++++----- 9 files changed, 121 insertions(+), 45 deletions(-) diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 2de9a6fbc39d..5e3f0069f933 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -354,8 +354,10 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( framesRead = std::clamp(framesRead, 0, numFrames); if (framesRead < numFrames) { - size_t bytesDone = static_cast(framesRead) * stream->getChannelCount() * sizeof(float); - size_t totalBytes = static_cast(numFrames) * stream->getChannelCount() * sizeof(float); + // Cache channel count in a local to avoid two virtual dispatches. + int32_t ch = stream->getChannelCount(); + size_t bytesDone = static_cast(framesRead) * ch * sizeof(float); + size_t totalBytes = static_cast(numFrames) * ch * sizeof(float); memset(static_cast(audioData) + bytesDone, 0, totalBytes - bytesDone); } } else { diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 79d84437a2ae..ed6992636772 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -187,6 +187,9 @@ public partial class OsuGameAndroid : OsuGame private IntPtr adpfInputSession; private double inputAdpfAccumulatedMs; private long inputAdpfLastReportMs; // Environment.TickCount64 ms timestamp of last ADPF report + // Cached display-frame period in ms. Recomputed in applyDisplayMode so the hot + // input callback (which can fire at ~100 kHz) reads a plain field, not Math.Round. + private long adpfInputIntervalMs = 8L; // One-shot System.Threading.Timer that runs a burst of background-thread taming passes // when the user transitions into active gameplay. Cancelled and replaced on each new @@ -1489,6 +1492,8 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And try { currentRefreshRate = (int)mode.RefreshRate; + // Cache the interval so the input hot-path avoids Math.Round on every poll. + adpfInputIntervalMs = currentRefreshRate > 0 ? (long)Math.Round(1000.0 / currentRefreshRate) : 8L; // Request the refresh rate via Surface.setFrameRate() ONLY. // @@ -1611,13 +1616,11 @@ private void onInputFrameCompleted() inputAdpfAccumulatedMs += elapsedMs; - // Report once per display frame period. `currentRefreshRate` is an int written - // from the UI thread — a torn or stale read is harmless (worst-case we use a - // slightly wrong interval for one report cycle). - long intervalMs = currentRefreshRate > 0 ? (long)Math.Round(1000.0 / currentRefreshRate) : 8L; + // Report once per display frame period. `adpfInputIntervalMs` is pre-computed + // in applyDisplayMode() so this callback reads a plain field instead of calling Math.Round. long nowMs = System.Environment.TickCount64; - if (nowMs - inputAdpfLastReportMs >= intervalMs) + if (nowMs - inputAdpfLastReportMs >= adpfInputIntervalMs) { OboeAudioBridge.nADPFReportActualDuration(adpfInputSession, (long)(inputAdpfAccumulatedMs * 1_000_000.0)); inputAdpfAccumulatedMs = 0; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index e22e1d2001ba..bce4aee07b04 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -301,7 +301,10 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) ApplyResult(static (r, hitObject) => { int totalTicks = hitObject.NestedHitObjects.Count; - int hitTicks = hitObject.NestedHitObjects.Count(h => h.IsHit); + int hitTicks = 0; + + foreach (var h in hitObject.NestedHitObjects) + if (h.IsHit) hitTicks++; if (hitTicks == totalTicks) r.Type = HitResult.Great; @@ -320,7 +323,18 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) // But the slider needs to still be judged with a reasonable hit/miss result for visual purposes (hit/miss transforms, etc). ApplyResult(static (r, hitObject) => { - r.Type = hitObject.NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult; + bool anyHit = false; + + foreach (var h in hitObject.NestedHitObjects) + { + if (h.Result.IsHit) + { + anyHit = true; + break; + } + } + + r.Type = anyHit ? r.Judgement.MaxResult : r.Judgement.MinResult; }); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 64cedd216bd6..954e50e63478 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -143,7 +143,7 @@ protected override void LoadSamples() { base.LoadSamples(); - spinningSample.Samples = HitObject.CreateSpinningSamples().Cast().ToArray(); + spinningSample.Samples = (ISampleInfo[])HitObject.CreateSpinningSamples().ToArray(); spinningSample.Frequency.Value = spinning_sample_initial_frequency; maxBonusSample.Samples = new ISampleInfo[] { new SpinnerBonusMaxSampleInfo(HitObject.CreateHitSampleInfo()) }; @@ -254,9 +254,12 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) if (userTriggered || Time.Current < HitObject.EndTime) return; - // Trigger a miss result for remaining ticks to avoid infinite gameplay. - foreach (var tick in ticks.Where(t => !t.Result.HasResult)) - tick.TriggerResult(false); + // Manual loop avoids allocating a LINQ delegate on spinner end. + foreach (var tick in ticks) + { + if (!tick.Result.HasResult) + tick.TriggerResult(false); + } ApplyResult(static (r, hitObject) => { @@ -348,7 +351,17 @@ private void updateBonusScore() while (completedFullSpins.Value != spins) { - var tick = ticks.FirstOrDefault(t => !t.Result.HasResult); + // Manual forward scan avoids allocating a LINQ delegate per spin completion. + DrawableSpinnerTick? tick = null; + + foreach (var t in ticks) + { + if (!t.Result.HasResult) + { + tick = t; + break; + } + } // tick may be null if we've hit the spin limit. if (tick == null) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index c75ae35a1a8a..cf3fc233a862 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -93,8 +93,11 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) bool allTicksInRange = true; - foreach (var nested in slider.NestedHitObjects.OfType()) + foreach (var nestedObj in slider.NestedHitObjects) { + var nested = nestedObj as DrawableOsuHitObject; + if (nested == null) continue; + // Skip nested objects that are already judged. if (nested.Judged) continue; @@ -117,8 +120,11 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) } } - foreach (var nested in slider.NestedHitObjects.OfType()) + foreach (var nestedObj in slider.NestedHitObjects) { + var nested = nestedObj as DrawableOsuHitObject; + if (nested == null) continue; + // Skip nested objects that are already judged. if (nested.Judged) continue; @@ -158,7 +164,20 @@ public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeO // // This covers the edge case where the lenience may allow the tail to activate before // the last tick, changing ordering of score/combo awarding. - var lastTick = slider.NestedHitObjects.LastOrDefault(o => o.HitObject is SliderTick || o.HitObject is SliderRepeat); + // Find the last tick/repeat without allocating a LINQ delegate. + DrawableHitObject? lastTick = null; + + for (int i = slider.NestedHitObjects.Count - 1; i >= 0; i--) + { + var obj = slider.NestedHitObjects[i]; + + if (obj.HitObject is SliderTick || obj.HitObject is SliderRepeat) + { + lastTick = obj; + break; + } + } + if (lastTick?.Judged == false) return; diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 694b223632ed..34b2b8d19193 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -389,12 +389,13 @@ protected virtual void OnFree() /// protected virtual void LoadSamples() { - var samples = GetSamples().ToArray(); + // HitSampleInfo : ISampleInfo, so array covariance lets us avoid a second .Cast().ToArray() allocation. + var samples = (ISampleInfo[])GetSamples().ToArray(); if (samples.Length <= 0) return; - Samples.Samples = samples.Cast().ToArray(); + Samples.Samples = samples; } private void onNewResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnNewResult?.Invoke(drawableHitObject, result); diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index bb9946af80c4..9a5c16ebc91a 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -175,6 +175,10 @@ public partial class ScoreProcessor : JudgementProcessor /// private double scoreMultiplier = 1; + // Cached on every Mods.ValueChanged to avoid allocating a new OfType<> enumerator + // on every hit (updateRank() fires from Accuracy.ValueChanged which is raised per judgement). + private IApplicableToScoreProcessor[] applicableScoreMods = []; + public Dictionary MaximumStatistics { get @@ -211,6 +215,9 @@ public ScoreProcessor(Ruleset ruleset) foreach (var m in mods.NewValue) scoreMultiplier *= m.ScoreMultiplier; + // Rebuild the cached array so updateRank() can iterate without any heap allocation. + applicableScoreMods = mods.NewValue.OfType().ToArray(); + updateScore(); updateRank(); }; @@ -395,7 +402,7 @@ private void updateRank() ScoreRank newRank = RankFromScore(Accuracy.Value, ScoreResultCounts); - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in applicableScoreMods) newRank = mod.AdjustRank(newRank, Accuracy.Value); rank.Value = newRank; diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index 5f5a24fda858..c11b76675109 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -30,6 +30,10 @@ public partial class GameplaySampleTriggerSource : CompositeDrawable private readonly Container hitSounds; + // Reusable scratch list for iterative depth-first traversal of nested hit objects. + // Avoids allocating iterator state-machine objects on every Play() call. + private readonly List nestedScratch = new List(); + private HitObjectLifetimeEntry? mostValidObject; [Resolved] @@ -64,10 +68,9 @@ public virtual void Play() if (nextObject == null) return; - // HitSampleInfo implements ISampleInfo, so array covariance lets us skip .Cast<>(). - var samples = nextObject.Samples.ToArray(); - - PlaySamples(samples); + // HitSampleInfo implements ISampleInfo, so array covariance lets us cast without + // allocating a second array via .Cast().ToArray(). + PlaySamples((ISampleInfo[])nextObject.Samples.ToArray()); } protected virtual void PlaySamples(ISampleInfo[] samples) => Schedule(() => @@ -149,13 +152,17 @@ protected override void Update() // Else we want the earliest valid nested. // In cases of nested objects, they will always have earlier sample data than their parent object. - // Single-pass scan avoids the OrderBy + SkipWhile + FirstOrDefault LINQ chain. + // Iterative DFS with a shared scratch list avoids per-call state-machine allocations from recursive yield return. double referenceTime = getReferenceTime(); HitObject? best = null; double bestEnd = double.MaxValue; - foreach (var nested in getAllNested(mostValidObject.HitObject)) + nestedScratch.Clear(); + nestedScratch.AddRange(mostValidObject.HitObject.NestedHitObjects); + + for (int i = 0; i < nestedScratch.Count; i++) { + var nested = nestedScratch[i]; double end = nested.GetEndTime(); if (end > referenceTime && end < bestEnd) @@ -163,6 +170,10 @@ protected override void Update() best = nested; bestEnd = end; } + + // Enqueue children for depth-first traversal. + if (nested.NestedHitObjects.Count > 0) + nestedScratch.AddRange(nested.NestedHitObjects); } return best ?? mostValidObject.HitObject; @@ -173,17 +184,6 @@ protected override void Update() private double getReferenceTime() => gameplayClock?.CurrentTime ?? Clock.CurrentTime; - private IEnumerable getAllNested(HitObject hitObject) - { - foreach (var h in hitObject.NestedHitObjects) - { - yield return h; - - foreach (var n in getAllNested(h)) - yield return n; - } - } - protected SkinnableSound GetNextSample() { SkinnableSound hitSound = hitSounds[nextHitSoundIndex]; diff --git a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs index 60e94299b26a..6567fb365903 100644 --- a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs @@ -140,17 +140,34 @@ private static int compareDifficulty(BeatmapInfo a, BeatmapInfo b, SortMode sort private static int compareUsingAggregateMax(BeatmapInfo a, BeatmapInfo b, Func func) { - var aMatchedBeatmaps = a.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); - var bMatchedBeatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); + double aMax = aggregateMax(a.BeatmapSet!.Beatmaps, func); + double bMax = aggregateMax(b.BeatmapSet!.Beatmaps, func); - bool aAny = aMatchedBeatmaps.Any(); - bool bAny = bMatchedBeatmaps.Any(); + if (double.IsNegativeInfinity(aMax) && double.IsNegativeInfinity(bMax)) return 0; + if (double.IsNegativeInfinity(aMax)) return -1; + if (double.IsNegativeInfinity(bMax)) return 1; - if (!aAny && !bAny) return 0; - if (!aAny) return -1; - if (!bAny) return 1; + return aMax.CompareTo(bMax); + } + + /// + /// Returns the maximum value of over all non-hidden beatmaps in + /// , or if every beatmap + /// is hidden. Single-pass, allocation-free (no LINQ enumerator). + /// + private static double aggregateMax(IReadOnlyList beatmaps, Func func) + { + double max = double.NegativeInfinity; + + foreach (var b in beatmaps) + { + if (b.Hidden) continue; + + double v = func(b); + if (v > max) max = v; + } - return aMatchedBeatmaps.Max(func).CompareTo(bMatchedBeatmaps.Max(func)); + return max; } } } From 32c67321533a5677ae01f8c799219cdfcec2647c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:02:08 +0000 Subject: [PATCH 07/16] perf: second-pass optimizations (LegacyHitPolicy single-pass, DrawableSlider covariance, Catcher.computePositionInStack manual loop) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/60401bc7-7f04-4693-b4fb-40a5851d015a Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Catch/UI/Catcher.cs | 13 ++++- .../Objects/Drawables/DrawableSlider.cs | 6 ++- osu.Game.Rulesets.Osu/UI/LegacyHitPolicy.cs | 47 ++++++++++--------- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 98a25da9a796..f7b62264c417 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -390,7 +390,7 @@ private Vector2 computePositionInStack(Vector2 position, float displayRadius) float adjustedRadius = displayRadius * lenience_adjust; float checkDistance = MathF.Pow(adjustedRadius, 2); - while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance)) + while (tooCloseToExistingObject(position, checkDistance)) { position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius); position.Y -= RNG.NextSingle(0, 5); @@ -399,6 +399,17 @@ private Vector2 computePositionInStack(Vector2 position, float displayRadius) return position; } + private bool tooCloseToExistingObject(Vector2 position, float checkDistance) + { + foreach (var f in caughtObjectContainer) + { + if (Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance) + return true; + } + + return false; + } + private void addLighting(JudgementResult judgementResult, Color4 colour, float x) => hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, judgementResult, colour, x)); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index bce4aee07b04..d4de48e28578 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -164,8 +164,10 @@ protected override void LoadSamples() { // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. - Samples.Samples = HitObject.TailSamples.Cast().ToArray(); - slidingSample.Samples = HitObject.CreateSlidingSamples().Cast().ToArray(); + // HitSampleInfo : ISampleInfo (reference type) — array covariance lets us cast directly, + // avoiding a second array allocation from .Cast().ToArray(). + Samples.Samples = (ISampleInfo[])HitObject.TailSamples.ToArray(); + slidingSample.Samples = (ISampleInfo[])HitObject.CreateSlidingSamples().ToArray(); } public override void StopAllSamples() diff --git a/osu.Game.Rulesets.Osu/UI/LegacyHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/LegacyHitPolicy.cs index daf498581ef3..e965305b8e8a 100644 --- a/osu.Game.Rulesets.Osu/UI/LegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/LegacyHitPolicy.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -38,33 +37,39 @@ public virtual ClickAction CheckHittable(DrawableHitObject hitObject, double tim if (HitObjectContainer == null) throw new InvalidOperationException($"{nameof(HitObjectContainer)} should be set before {nameof(CheckHittable)} is called."); - var aliveObjects = HitObjectContainer.AliveObjects.ToList(); - int index = aliveObjects.IndexOf(hitObject); + // AliveObjects already returns a new sorted List from getSortedAliveObjects(). + // Calling .ToList() on the IEnumerable<> would copy that list a second time. + // Single-pass over the enumerable uses only the one allocation that sorting requires. + DrawableOsuHitObject? prevObject = null; + bool foundHitObject = false; + bool orderBlocked = false; - if (index > 0) + foreach (DrawableHitObject alive in HitObjectContainer.AliveObjects) { - var previousHitObject = (DrawableOsuHitObject)aliveObjects[index - 1]; - if (previousHitObject.HitObject.StackHeight > 0 && !previousHitObject.AllJudged) - return ClickAction.Ignore; + // We only care about objects that come before hitObject in start-time order. + if (alive == hitObject) + { + foundHitObject = true; + break; + } + + prevObject = (DrawableOsuHitObject)alive; + + // Note-lock: any unjudged preceding object whose window ends well before hitObject starts blocks the hit. + if (!alive.AllJudged && alive.HitObject.GetEndTime() + 3 < hitObject.HitObject.StartTime) + orderBlocked = true; } + // Stack-height check: uses the immediately preceding alive object (index - 1 equivalent). + // Only applies when hitObject is actually in the alive list and has a predecessor. + if (foundHitObject && prevObject != null && prevObject.HitObject.StackHeight > 0 && !prevObject.AllJudged) + return ClickAction.Ignore; + if (result == HitResult.None) return ClickAction.Shake; - foreach (DrawableHitObject testObject in aliveObjects) - { - if (testObject.AllJudged) - continue; - - // if we found the object being checked, we can move on to the final timing test. - if (testObject == hitObject) - break; - - // for all other objects, we check for validity and block the hit if any are still valid. - // 3ms of extra leniency to account for slightly unsnapped objects. - if (testObject.HitObject.GetEndTime() + 3 < hitObject.HitObject.StartTime) - return ClickAction.Shake; - } + if (orderBlocked) + return ClickAction.Shake; return Math.Abs(hitObject.HitObject.StartTime - time) < hittableRange ? ClickAction.Hit : ClickAction.Shake; } From 7940a25f4bee958feed12512ff7e334c97e80a91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:10:39 +0000 Subject: [PATCH 08/16] Perf: third-pass hot-path allocation fixes - HitObjectContainer.AliveObjects: replace per-call new List + Sort with a dirty-flagged persistent cache. The list is rebuilt only when addDrawable / removeDrawable fires, eliminating allocations on every hit-policy check and every frame in LegacyCursorParticles/OsuModRelax (HIGH impact). - DrawableHitCircle.DimmablePieces: cache the single-element Drawable[] as a field assigned in load() instead of allocating a new array on each property access from UpdateInitialTransforms / ClearNestedHitObjects (MEDIUM impact). - DrawableOsuHitObject.ClearNestedHitObjects: replace DimmablePieces.OfType<>() LINQ iterator with a plain foreach + 'is DrawableHitObject' pattern-match, eliminating the LINQ state-machine allocation on every pool return (MEDIUM). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Objects/Drawables/DrawableHitCircle.cs | 5 +++- .../Objects/Drawables/DrawableOsuHitObject.cs | 7 +++-- osu.Game/Rulesets/UI/HitObjectContainer.cs | 28 +++++++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 101c34b7250d..4eeb876eb4ea 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -33,7 +33,8 @@ public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircl public HitReceptor HitArea { get; private set; } = null!; public SkinnableDrawable CirclePiece { get; private set; } = null!; - protected override IEnumerable DimmablePieces => new[] { CirclePiece }; + protected override IEnumerable DimmablePieces => dimmablePieces; + private Drawable[] dimmablePieces = null!; Drawable IHasApproachCircle.ApproachCircle => ApproachCircle; @@ -96,6 +97,8 @@ private void load() Size = HitArea.DrawSize; + dimmablePieces = new Drawable[] { CirclePiece }; + PositionBindable.BindValueChanged(_ => UpdatePosition()); StackHeightBindable.BindValueChanged(_ => UpdatePosition()); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 45f69705a880..273515a93771 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -108,8 +108,11 @@ protected override void ClearNestedHitObjects() // and because of separate pooling of parent and child objects, there is no guarantee that the pieces will be associated with `this` again on re-use. // therefore, clean up the subscription here to avoid crosstalk. // not doing so can result in the callback attempting to read things from `this` when it is in a completely bogus state (not in use or similar). - foreach (var piece in DimmablePieces.OfType()) - piece.ApplyCustomUpdateState -= applyDimToDrawableHitObject; + foreach (var piece in DimmablePieces) + { + if (piece is DrawableHitObject dho) + dho.ApplyCustomUpdateState -= applyDimToDrawableHitObject; + } } private void applyDim(Drawable piece) diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 81d183334e42..61e3ed2c090e 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -32,11 +32,14 @@ public partial class HitObjectContainer : PooledDrawableWithLifetimeContainer /// /// The alive entries dictionary is unordered, so we must sort. - /// However, the alive set is typically much smaller than the full set, making this cheaper - /// than sorting all children. We use a List + Sort (in-place) to avoid LINQ iterator allocations. + /// A persistent sorted list is maintained and rebuilt only when the alive set changes, + /// avoiding per-call allocations that would occur when called every frame (e.g. cursor particles). /// public IEnumerable AliveObjects => getSortedAliveObjects(); + private readonly List aliveObjectsSortedCache = new List(); + private bool aliveObjectsCacheDirty = true; + private IEnumerable enumerateByStartTimeAscending() { var children = InternalChildren; @@ -48,11 +51,20 @@ private IEnumerable enumerateByStartTimeAscending() } } - private List getSortedAliveObjects() + private IEnumerable getSortedAliveObjects() { - var list = new List(AliveEntries.Values); - list.Sort(static (a, b) => a.HitObject.StartTime.CompareTo(b.HitObject.StartTime)); - return list; + if (aliveObjectsCacheDirty) + { + aliveObjectsSortedCache.Clear(); + + foreach (var dho in AliveEntries.Values) + aliveObjectsSortedCache.Add(dho); + + aliveObjectsSortedCache.Sort(static (a, b) => a.HitObject.StartTime.CompareTo(b.HitObject.StartTime)); + aliveObjectsCacheDirty = false; + } + + return aliveObjectsSortedCache; } /// @@ -137,6 +149,8 @@ protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHit private void addDrawable(DrawableHitObject drawable) { + aliveObjectsCacheDirty = true; + drawable.OnNewResult += onNewResult; bindStartTime(drawable); @@ -145,6 +159,8 @@ private void addDrawable(DrawableHitObject drawable) private void removeDrawable(DrawableHitObject drawable) { + aliveObjectsCacheDirty = true; + drawable.OnNewResult -= onNewResult; unbindStartTime(drawable); From 4d7aa83822563d0633809acbe423a7f7d604a746 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:15:36 +0000 Subject: [PATCH 09/16] perf: third-pass - eliminate OfType LINQ allocations in OsuModRelax/StrictTracking/Bubbles hot paths Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/60401bc7-7f04-4693-b4fb-40a5851d015a Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs | 12 +++++++++++- osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs | 6 +++++- osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs | 14 ++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs index b706e07a5550..68b9bb0aaa53 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs @@ -111,7 +111,17 @@ public void ApplyToDrawableHitObject(DrawableHitObject drawableObject) { if (drawable.HitObject is SpinnerTick or Slider) return; - BubbleDrawable? lastBubble = bubbleContainer.OfType().LastOrDefault(); + // Reverse linear scan avoids the OfType().LastOrDefault() LINQ allocations. + BubbleDrawable? lastBubble = null; + + for (int i = bubbleContainer.Children.Count - 1; i >= 0; i--) + { + if (bubbleContainer.Children[i] is BubbleDrawable b) + { + lastBubble = b; + break; + } + } lastBubble?.ClearTransforms(); lastBubble?.Expire(true); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 71de3c269b5f..5a039357b5d5 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -80,8 +80,12 @@ public void Update(Playfield playfield) double time = playfield.Clock.CurrentTime; - foreach (var h in playfield.HitObjectContainer.AliveObjects.OfType()) + foreach (DrawableHitObject dho in playfield.HitObjectContainer.AliveObjects) { + // All alive objects in an osu! playfield are DrawableOsuHitObjects. + // Casting inline avoids the OfType<> LINQ state-machine allocation per frame. + if (dho is not DrawableOsuHitObject h) + continue; // we are not yet close enough to the object. if (time < h.HitObject.StartTime - RELAX_LENIENCY) break; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 06d49849bc10..9308aa2de476 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -46,9 +46,19 @@ public void ApplyToDrawableHitObject(DrawableHitObject drawable) if (slider.Clock is IGameplayClock { IsRewinding: true }) return; - var tail = slider.NestedHitObjects.OfType().First(); + // Manual scan avoids allocating a LINQ state-machine on every tracking-change event. + StrictTrackingDrawableSliderTail? tail = null; - if (!tail.Judged) + foreach (var nested in slider.NestedHitObjects) + { + if (nested is StrictTrackingDrawableSliderTail t) + { + tail = t; + break; + } + } + + if (tail != null && !tail.Judged) tail.MissForcefully(); }; } From a6e65e8e00a9ef4a338d586104bd7c78b53a9b94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:36:10 +0000 Subject: [PATCH 10/16] fix: CI build errors + fourth-pass perf (IList cast, IDE0004/IDE0032, touch/hit-policy/relax/taiko allocs) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/16daf401-e98b-42e6-8464-4745383d6e40 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs | 6 +++--- .../OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs | 9 ++++----- osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index c11b76675109..c1098cc0d1a7 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -68,9 +68,9 @@ public virtual void Play() if (nextObject == null) return; - // HitSampleInfo implements ISampleInfo, so array covariance lets us cast without - // allocating a second array via .Cast().ToArray(). - PlaySamples((ISampleInfo[])nextObject.Samples.ToArray()); + // HitSampleInfo : ISampleInfo (reference type) — array covariance makes the assignment + // implicit; no explicit cast or second array allocation needed. + PlaySamples(nextObject.Samples.ToArray()); } protected virtual void PlaySamples(ISampleInfo[] samples) => Schedule(() => diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs index e13edadb5a1e..75bb283439b5 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs @@ -23,28 +23,27 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue /// public partial class CloudVisualisation : CompositeDrawable { - private APIUser[] users = []; private Container usersContainer = null!; private readonly Bindable lastSamplePlayback = new Bindable(); public APIUser[] Users { - get => users; + get; set { - users = value; + field = value; if (IsLoaded) refresh(); } - } + } = []; private void refresh() { foreach (var u in usersContainer) u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire(); - LoadComponentsAsync(users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => + LoadComponentsAsync(Users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => { if (usersContainer.Count == 0) { diff --git a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs index 6567fb365903..a5fd7bb2f98e 100644 --- a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs @@ -155,7 +155,7 @@ private static int compareUsingAggregateMax(BeatmapInfo a, BeatmapInfo b, Func, or if every beatmap /// is hidden. Single-pass, allocation-free (no LINQ enumerator). /// - private static double aggregateMax(IReadOnlyList beatmaps, Func func) + private static double aggregateMax(IList beatmaps, Func func) { double max = double.NegativeInfinity; From 7bddedd64ed20e8757fe6a6316714621c2725708 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 17:47:01 +0000 Subject: [PATCH 11/16] fix: CI errors batch 2 - missing using, nullable, pattern matching, redundant casts, unused usings Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/45232cd1-6884-4c60-815e-dba288232c8c Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Catch/UI/Catcher.cs | 1 - osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs | 1 + .../Objects/Drawables/DrawableOsuHitObject.cs | 1 - osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs | 4 ++-- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs | 4 ++-- .../Objects/Drawables/SliderInputManager.cs | 7 ++----- 6 files changed, 7 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index f7b62264c417..d3a5a07ab1ca 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -4,7 +4,6 @@ using System; using System.Buffers; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 5a039357b5d5..30ed67a46d2d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 273515a93771..ecae15aba77b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index d4de48e28578..e49e238fba55 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -166,8 +166,8 @@ protected override void LoadSamples() // HitSampleInfo : ISampleInfo (reference type) — array covariance lets us cast directly, // avoiding a second array allocation from .Cast().ToArray(). - Samples.Samples = (ISampleInfo[])HitObject.TailSamples.ToArray(); - slidingSample.Samples = (ISampleInfo[])HitObject.CreateSlidingSamples().ToArray(); + Samples.Samples = HitObject.TailSamples.ToArray(); + slidingSample.Samples = HitObject.CreateSlidingSamples().ToArray(); } public override void StopAllSamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 954e50e63478..b0abb0242f4e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -143,7 +143,7 @@ protected override void LoadSamples() { base.LoadSamples(); - spinningSample.Samples = (ISampleInfo[])HitObject.CreateSpinningSamples().ToArray(); + spinningSample.Samples = HitObject.CreateSpinningSamples().ToArray(); spinningSample.Frequency.Value = spinning_sample_initial_frequency; maxBonusSample.Samples = new ISampleInfo[] { new SpinnerBonusMaxSampleInfo(HitObject.CreateHitSampleInfo()) }; @@ -352,7 +352,7 @@ private void updateBonusScore() while (completedFullSpins.Value != spins) { // Manual forward scan avoids allocating a LINQ delegate per spin completion. - DrawableSpinnerTick? tick = null; + DrawableSpinnerTick tick = null; foreach (var t in ticks) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index cf3fc233a862..9851b9c08fc4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input; @@ -95,8 +94,7 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) foreach (var nestedObj in slider.NestedHitObjects) { - var nested = nestedObj as DrawableOsuHitObject; - if (nested == null) continue; + if (nestedObj is not DrawableOsuHitObject nested) continue; // Skip nested objects that are already judged. if (nested.Judged) @@ -122,8 +120,7 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) foreach (var nestedObj in slider.NestedHitObjects) { - var nested = nestedObj as DrawableOsuHitObject; - if (nested == null) continue; + if (nestedObj is not DrawableOsuHitObject nested) continue; // Skip nested objects that are already judged. if (nested.Judged) From da43f16bbe81d7446cda2ef49aea28be43da7c8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 18:20:01 +0000 Subject: [PATCH 12/16] fix: remove unused osu.Game.Audio using in DrawableSlider.cs (IDE0005) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/7ba7b342-765f-428b-96e1-3925b8e2d9ab Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index e49e238fba55..d79bb94745d6 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; -using osu.Game.Audio; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -163,9 +162,6 @@ protected override void OnFree() protected override void LoadSamples() { // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. - - // HitSampleInfo : ISampleInfo (reference type) — array covariance lets us cast directly, - // avoiding a second array allocation from .Cast().ToArray(). Samples.Samples = HitObject.TailSamples.ToArray(); slidingSample.Samples = HitObject.CreateSlidingSamples().ToArray(); } From 17febd88b3505b51f36166d249014bbb83ab1741 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 04:36:23 +0000 Subject: [PATCH 13/16] fix: add partial to AndroidBackgroundDataStoreProcessor (Android DI source gen warning) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/e95ab934-869c-4165-ad22-563b0f51c6b8 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Android/AndroidBackgroundDataStoreProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Android/AndroidBackgroundDataStoreProcessor.cs b/osu.Android/AndroidBackgroundDataStoreProcessor.cs index 0ea9334426c7..80e139c81956 100644 --- a/osu.Android/AndroidBackgroundDataStoreProcessor.cs +++ b/osu.Android/AndroidBackgroundDataStoreProcessor.cs @@ -19,7 +19,7 @@ namespace osu.Android /// per typical 5-minute play session; at 120 s they drop to ~2×, cutting the associated /// allocation pressure and the risk of a GC stall at the worst possible moment. /// - public class AndroidBackgroundDataStoreProcessor : BackgroundDataStoreProcessor + public partial class AndroidBackgroundDataStoreProcessor : BackgroundDataStoreProcessor { // 2-minute polling interval while gameplay is active (vs. the default 30 s). protected override int TimeToSleepDuringGameplay => 120_000; From 3cd3d9096ca3a0b8e3bbf703f6cfec5abbc72233 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 04:49:55 +0000 Subject: [PATCH 14/16] fix: resolve co-variant array conversion warnings (HitSampleInfo[] to ISampleInfo[]) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/258720a5-84bc-4283-b524-809219539b6a Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs | 4 ++-- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs | 3 ++- osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs | 3 +-- osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index d79bb94745d6..8aeee886ac9a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -162,8 +162,8 @@ protected override void OnFree() protected override void LoadSamples() { // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. - Samples.Samples = HitObject.TailSamples.ToArray(); - slidingSample.Samples = HitObject.CreateSlidingSamples().ToArray(); + Samples.Samples = HitObject.TailSamples.Cast().ToArray(); + slidingSample.Samples = HitObject.CreateSlidingSamples().Cast().ToArray(); } public override void StopAllSamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index b0abb0242f4e..daacd3d3ab6c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -143,7 +143,8 @@ protected override void LoadSamples() { base.LoadSamples(); - spinningSample.Samples = HitObject.CreateSpinningSamples().ToArray(); + spinningSample.Samples = HitObject.CreateSpinningSamples().Cast().ToArray(); + spinningSample.Frequency.Value = spinning_sample_initial_frequency; maxBonusSample.Samples = new ISampleInfo[] { new SpinnerBonusMaxSampleInfo(HitObject.CreateHitSampleInfo()) }; diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 34b2b8d19193..0566c49acc82 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -389,8 +389,7 @@ protected virtual void OnFree() /// protected virtual void LoadSamples() { - // HitSampleInfo : ISampleInfo, so array covariance lets us avoid a second .Cast().ToArray() allocation. - var samples = (ISampleInfo[])GetSamples().ToArray(); + var samples = GetSamples().Cast().ToArray(); if (samples.Length <= 0) return; diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index c1098cc0d1a7..ba48abf407ea 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -68,9 +68,7 @@ public virtual void Play() if (nextObject == null) return; - // HitSampleInfo : ISampleInfo (reference type) — array covariance makes the assignment - // implicit; no explicit cast or second array allocation needed. - PlaySamples(nextObject.Samples.ToArray()); + PlaySamples(nextObject.Samples.Cast().ToArray()); } protected virtual void PlaySamples(ISampleInfo[] samples) => Schedule(() => From 31a95b06a8dd9c7b77160d248ba559c7a2f4d846 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 05:07:48 +0000 Subject: [PATCH 15/16] fix: add missing `using osu.Game.Audio` to DrawableSlider.cs (CS0246 ISampleInfo) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/6590546f-8ba3-432c-af4d-2128d82c40f4 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 8aeee886ac9a..e5ff2eddba46 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; +using osu.Game.Audio; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; From aa829ede80e3e2616c4739a1b9811db69afb1336 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 05:23:08 +0000 Subject: [PATCH 16/16] fix: add braces and line break for foreach/if in DrawableSlider (InspectCode warning) Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/3392b526-2e9e-4809-aa6c-cb6622e9295d Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index e5ff2eddba46..46f2dbf2248e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -303,7 +303,10 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) int hitTicks = 0; foreach (var h in hitObject.NestedHitObjects) - if (h.IsHit) hitTicks++; + { + if (h.IsHit) + hitTicks++; + } if (hitTicks == totalTicks) r.Type = HitResult.Great;