From e85ac22be164b9ac6aab1919f936acdbb86303b3 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 21 May 2026 22:57:34 +0900 Subject: [PATCH 01/19] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index d3d06fa7f9df..30b85a35ab7f 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index 7e1e3020a288..9241a78302d7 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + From 5b24de3a1522c79f2a90de0e2766de27e8956cee Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 21 May 2026 23:00:28 +0900 Subject: [PATCH 02/19] Update resources --- osu.Game/osu.Game.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 093bb4788927..209ea81391fc 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -40,7 +40,7 @@ - + From a31eadc2840dc2adce3eaf14de1bb053826ad19d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:52:21 +0000 Subject: [PATCH 03/19] Add session-level playable beatmap cache + persisted slider pool metadata Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/24ea579f-da4d-4cf3-8782-8be3ff5d6119 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs | 31 +++- .../Models/TournamentBeatmap.cs | 2 + osu.Game/Beatmaps/BeatmapInfo.cs | 7 + osu.Game/Beatmaps/BeatmapInfoExtensions.cs | 9 ++ osu.Game/Beatmaps/BeatmapUpdater.cs | 5 + osu.Game/Beatmaps/IBeatmapInfo.cs | 7 + osu.Game/Beatmaps/PlayableBeatmapCache.cs | 132 ++++++++++++++++++ .../Database/BackgroundDataStoreProcessor.cs | 2 +- osu.Game/Database/RealmAccess.cs | 11 +- .../API/Requests/Responses/APIBeatmap.cs | 3 + osu.Game/OsuGameBase.cs | 6 + .../LegacyBeatmapConversionDifficultyInfo.cs | 5 + osu.Game/Screens/Play/Player.cs | 17 +++ 13 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 osu.Game/Beatmaps/PlayableBeatmapCache.cs diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 822915648595..5b5f18d0314b 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -141,15 +141,36 @@ private void load(OsuRulesetConfigManager? config, IBeatmap? beatmap) RegisterPool(20, 100); // handle edge cases where a beatmap has a slider with many repeats. - int maxRepeatsOnOneSlider = 0; + int maxRepeatsOnOneSlider; int maxTicksOnOneSlider = 0; - if (osuBeatmap != null) + // Use persisted metadata when available to avoid a full beatmap scan. + // Fall back to scanning the loaded beatmap if metadata has not yet been computed. + int persistedMaxRepeats = beatmap?.BeatmapInfo.MaxSliderRepeats ?? -1; + + if (persistedMaxRepeats >= 0) { - foreach (var slider in osuBeatmap.HitObjects.OfType()) + maxRepeatsOnOneSlider = persistedMaxRepeats; + + // Still scan for tick counts since MaxSliderTicks is not yet persisted. + if (osuBeatmap != null) + { + foreach (var slider in osuBeatmap.HitObjects.OfType()) + maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType().Count()); + } + } + else + { + // Fallback: scan all sliders when persisted metadata is not available. + maxRepeatsOnOneSlider = 0; + + if (osuBeatmap != null) { - maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount); - maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType().Count()); + foreach (var slider in osuBeatmap.HitObjects.OfType()) + { + maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount); + maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType().Count()); + } } } diff --git a/osu.Game.Tournament/Models/TournamentBeatmap.cs b/osu.Game.Tournament/Models/TournamentBeatmap.cs index 72669c0ca7e4..3fafc60d0de7 100644 --- a/osu.Game.Tournament/Models/TournamentBeatmap.cs +++ b/osu.Game.Tournament/Models/TournamentBeatmap.cs @@ -26,6 +26,8 @@ public class TournamentBeatmap : IBeatmapInfo, IBeatmapSetOnlineInfo public int TotalObjectCount { get; set; } + public int MaxSliderRepeats { get; set; } + public IBeatmapMetadataInfo Metadata { get; set; } = new BeatmapMetadata(); public IBeatmapDifficultyInfo Difficulty { get; set; } = new BeatmapDifficulty(); diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 1f4d370d13e5..a45326e0ce70 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -122,6 +122,13 @@ public BeatmapOnlineStatus Status public int TotalObjectCount { get; set; } = -1; + /// + /// The maximum number of repeats found on a single hit object in the beatmap. + /// Only relevant for rulesets that have such objects (e.g. osu! sliders, mania hold notes). + /// Defaults to -1 (meaning not-yet-calculated). + /// + public int MaxSliderRepeats { get; set; } = -1; + /// /// Reset any fetched online linking information (and history). /// diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index d25a17102393..46a118108713 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -21,6 +21,15 @@ public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBe beatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength(); beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; + + // Compute the maximum repeat count for pool sizing purposes. + // RepeatCount is set during decoding and is available on the raw (pre-ApplyDefaults) beatmap, + // so this can be derived without a full conversion pass. + beatmapInfo.MaxSliderRepeats = beatmap.HitObjects + .OfType() + .Select(h => h.RepeatCount) + .DefaultIfEmpty(0) + .Max(); } /// diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 559ba641248c..58c9572158cc 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -81,6 +81,11 @@ public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope loo beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; + beatmapInfo.MaxSliderRepeats = beatmap.HitObjects + .OfType() + .Select(h => h.RepeatCount) + .DefaultIfEmpty(0) + .Max(); // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. workingBeatmapCache.Invalidate(beatmapInfo); diff --git a/osu.Game/Beatmaps/IBeatmapInfo.cs b/osu.Game/Beatmaps/IBeatmapInfo.cs index 04c2017dedb2..5e363ae3a203 100644 --- a/osu.Game/Beatmaps/IBeatmapInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapInfo.cs @@ -77,5 +77,12 @@ public interface IBeatmapInfo : IHasOnlineID, IEquatable /// Defaults to -1 (meaning not-yet-calculated). /// int TotalObjectCount { get; } + + /// + /// The maximum number of repeats on a single hit object in the beatmap. + /// Only meaningful for rulesets that have repeating objects (e.g. osu! sliders). + /// Defaults to -1 (meaning not-yet-calculated). + /// + int MaxSliderRepeats { get; } } } diff --git a/osu.Game/Beatmaps/PlayableBeatmapCache.cs b/osu.Game/Beatmaps/PlayableBeatmapCache.cs new file mode 100644 index 000000000000..425aef3c37b7 --- /dev/null +++ b/osu.Game/Beatmaps/PlayableBeatmapCache.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Beatmaps +{ + /// + /// A session-level cache for post-conversion playable instances. + /// Avoids repeating the expensive conversion + ApplyDefaults pipeline when the same beatmap, + /// ruleset and mod combination is requested again (e.g. quick retry, replay reload, repeated plays). + /// + /// + /// + /// The cache stores one canonical (immutable) copy keyed by + /// (BeatmapInfo.ID, rulesetShortName, orderedModsKey). + /// Callers receive a shallow so that gameplay state does not + /// bleed across sessions while still reusing the pre-built hit-object graph. + /// + /// + /// All entries belonging to a particular are evicted whenever + /// fires for that beatmap (e.g. on beatmap + /// update or reimport). + /// + /// + public class PlayableBeatmapCache : Component + { + private readonly record struct CacheKey(Guid BeatmapId, string RulesetShortName, string ModsKey); + + private readonly Dictionary cache = new Dictionary(); + + private WorkingBeatmapCache? workingBeatmapCache; + + [BackgroundDependencyLoader] + private void load(IWorkingBeatmapCache beatmapCache) + { + if (beatmapCache is WorkingBeatmapCache concrete) + { + workingBeatmapCache = concrete; + workingBeatmapCache.OnInvalidated += handleInvalidated; + } + } + + /// + /// Try to retrieve a pre-built playable beatmap from the cache. + /// + /// The beatmap whose playable representation is requested. + /// The ruleset used for conversion. + /// The mods applied during conversion. + /// + /// On success, a shallow clone of the cached beatmap; ready for use in a new gameplay session. + /// + /// true if a cached entry was found; false otherwise. + public bool TryGetPlayableBeatmap(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList mods, [NotNullWhen(true)] out IBeatmap? playable) + { + var key = makeKey(beatmapInfo, ruleset, mods); + + lock (cache) + { + if (cache.TryGetValue(key, out var cached)) + { + playable = cached.Clone(); + return true; + } + } + + playable = null; + return false; + } + + /// + /// Store a playable beatmap in the cache so subsequent requests can reuse it. + /// + /// The beatmap whose playable representation is being stored. + /// The ruleset used for conversion. + /// The mods applied during conversion. + /// The fully-built playable beatmap to cache. + public void CachePlayableBeatmap(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList mods, IBeatmap playable) + { + var key = makeKey(beatmapInfo, ruleset, mods); + + lock (cache) + cache[key] = playable; + } + + private void handleInvalidated(WorkingBeatmap working) + { + Guid id = working.BeatmapInfo.ID; + + lock (cache) + { + int removed = 0; + + foreach (var key in cache.Keys.Where(k => k.BeatmapId == id).ToList()) + { + cache.Remove(key); + removed++; + } + + if (removed > 0) + Logger.Log($"Evicted {removed} playable beatmap cache entr{(removed == 1 ? "y" : "ies")} for {working.BeatmapInfo}"); + } + } + + private static CacheKey makeKey(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList mods) + { + // Build a deterministic key from ordered mod acronyms and their settings hash. + // Mod.GetHashCode() accounts for both the type and any user-adjustable settings. + string modsKey = string.Join(';', mods + .OrderBy(m => m.Acronym) + .Select(m => $"{m.Acronym}:{m.GetHashCode()}")); + + return new CacheKey(beatmapInfo.ID, ruleset.ShortName, modsKey); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (workingBeatmapCache != null) + workingBeatmapCache.OnInvalidated -= handleInvalidated; + } + } +} diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 9ca9542313be..e46035081552 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -295,7 +295,7 @@ private void processBeatmapsWithMissingObjectCounts() realmAccess.Run(r => { - foreach (var b in r.All().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0)) + foreach (var b in r.All().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0 || b.MaxSliderRepeats < 0)) beatmapIds.Add(b.ID); }); diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 4fb4bf4da2fe..adf553032620 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -101,8 +101,9 @@ public class RealmAccess : IDisposable /// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID. /// 50 2025-07-11 Add UserTags to BeatmapMetadata. /// 51 2025-07-22 Add ScoreInfo.Pauses. + /// 52 2025-??-?? Add MaxSliderRepeats to BeatmapInfo for osu! pool sizing metadata. /// - private const int schema_version = 51; + private const int schema_version = 52; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -1344,6 +1345,14 @@ void remapKeyBinding(int oldAction, int newAction) score.LegacyOnlineID = -1; break; + + case 52: + // New MaxSliderRepeats field on BeatmapInfo for osu! pool sizing. + // Set all existing entries to -1 so BackgroundDataStoreProcessor can backfill them. + foreach (var beatmap in migration.NewRealm.All()) + beatmap.MaxSliderRepeats = -1; + + break; } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 857ecf6b949e..a16be65b78a5 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -50,6 +50,9 @@ public class APIBeatmap : IBeatmapInfo, IBeatmapOnlineInfo public int TotalObjectCount => CircleCount + SliderCount + SpinnerCount; + // Not provided by the online API; callers that need this for pool sizing should fall back to an in-memory scan. + public int MaxSliderRepeats => 0; + [JsonProperty(@"drain")] public float DrainRate { get; set; } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index bb0fc9e66c31..b21845983661 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -475,6 +475,12 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider, Framewo // Add after all the above cache operations as it depends on them. base.Content.Add(difficultyCache); + // Session-level cache for fully-built (converted + ApplyDefaults) playable beatmaps. + // Registered here so that Player, the difficulty cache, and any other consumer can resolve it. + PlayableBeatmapCache playableBeatmapCache; + dependencies.Cache(playableBeatmapCache = new PlayableBeatmapCache()); + base.Content.Add(playableBeatmapCache); + // TODO: OsuGame or OsuGameBase? dependencies.CacheAs(beatmapUpdater = CreateBeatmapUpdater()); dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints)); diff --git a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs index f8b8567305bb..9ff0dc9c65c9 100644 --- a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs +++ b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs @@ -51,6 +51,11 @@ public class LegacyBeatmapConversionDifficultyInfo : IBeatmapDifficultyInfo /// public int TotalObjectCount { get; set; } + /// + /// The maximum number of repeats on a single hit object in the beatmap. + /// + public int MaxSliderRepeats { get; set; } + double IBeatmapDifficultyInfo.SliderMultiplier => 0; double IBeatmapDifficultyInfo.SliderTickRate => 0; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index ca87cd6d4b1d..2899cc75322b 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -139,6 +139,9 @@ public override bool RequiresPortraitOrientation [Resolved] private OsuGameBase game { get; set; } + [Resolved(canBeNull: true)] + private PlayableBeatmapCache? playableBeatmapCache { get; set; } + public GameplayState GameplayState { get; private set; } private Ruleset ruleset; @@ -599,6 +602,17 @@ private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods, CancellationToken cance var rulesetInfo = Ruleset.Value; ruleset = rulesetInfo.CreateInstance() ?? throw new RulesetLoadException("Instantiation failure"); + // Check the session-level playable beatmap cache before running the expensive + // convert + ApplyDefaults pipeline. This is the primary win for quick retry, + // replay/re-results reload, and repeated plays of the same map. + var beatmapInfo = Beatmap.Value.BeatmapInfo; + + if (playableBeatmapCache != null && playableBeatmapCache.TryGetPlayableBeatmap(beatmapInfo, ruleset.RulesetInfo, gameplayMods, out var cachedPlayable)) + { + Logger.Log($"Reusing cached playable beatmap for {Beatmap.Value}", LoggingTarget.Performance); + return cachedPlayable; + } + try { playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods, cancellationToken); @@ -614,6 +628,9 @@ private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods, CancellationToken cance Logger.Log("Beatmap contains no hit objects!", level: LogLevel.Important); return null; } + + // Store the freshly-built playable for future sessions with the same key. + playableBeatmapCache?.CachePlayableBeatmap(beatmapInfo, ruleset.RulesetInfo, gameplayMods, playable); } catch (OperationCanceledException) { From 89fe51a7cb19e07998d95bad31e43aad156feb55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:04:02 +0000 Subject: [PATCH 04/19] Add versioned playable prewarm and persisted slider ticks metadata Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/013bac5c-f284-486b-a01e-396d84a127ce Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs | 14 ++--- .../BeatmapInfoStartupMetadataTest.cs | 51 ++++++++++++++++ .../Beatmaps/PlayableBeatmapCacheTest.cs | 50 +++++++++++++++ .../Models/TournamentBeatmap.cs | 4 ++ osu.Game/Beatmaps/BeatmapInfo.cs | 7 +++ osu.Game/Beatmaps/BeatmapInfoExtensions.cs | 8 +++ osu.Game/Beatmaps/BeatmapUpdater.cs | 17 ++---- osu.Game/Beatmaps/IBeatmapInfo.cs | 7 +++ osu.Game/Beatmaps/PlayableBeatmapCache.cs | 16 +++-- .../Database/BackgroundDataStoreProcessor.cs | 2 +- osu.Game/Database/RealmAccess.cs | 11 +++- .../API/Requests/Responses/APIBeatmap.cs | 3 + .../LegacyBeatmapConversionDifficultyInfo.cs | 13 +++- osu.Game/Screens/Play/PlayerLoader.cs | 61 +++++++++++++++++++ 14 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs create mode 100644 osu.Game.Tests/Beatmaps/PlayableBeatmapCacheTest.cs diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 5b5f18d0314b..07c3938f6c86 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -142,27 +142,23 @@ private void load(OsuRulesetConfigManager? config, IBeatmap? beatmap) // handle edge cases where a beatmap has a slider with many repeats. int maxRepeatsOnOneSlider; - int maxTicksOnOneSlider = 0; + int maxTicksOnOneSlider; // Use persisted metadata when available to avoid a full beatmap scan. // Fall back to scanning the loaded beatmap if metadata has not yet been computed. int persistedMaxRepeats = beatmap?.BeatmapInfo.MaxSliderRepeats ?? -1; + int persistedMaxTicks = beatmap?.BeatmapInfo.MaxSliderTicks ?? -1; - if (persistedMaxRepeats >= 0) + if (persistedMaxRepeats >= 0 && persistedMaxTicks >= 0) { maxRepeatsOnOneSlider = persistedMaxRepeats; - - // Still scan for tick counts since MaxSliderTicks is not yet persisted. - if (osuBeatmap != null) - { - foreach (var slider in osuBeatmap.HitObjects.OfType()) - maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType().Count()); - } + maxTicksOnOneSlider = persistedMaxTicks; } else { // Fallback: scan all sliders when persisted metadata is not available. maxRepeatsOnOneSlider = 0; + maxTicksOnOneSlider = 0; if (osuBeatmap != null) { diff --git a/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs b/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs new file mode 100644 index 000000000000..2aad6af55507 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs @@ -0,0 +1,51 @@ +// 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 System.Numerics; +using System.Threading; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Beatmaps +{ + [TestFixture] + public class BeatmapInfoStartupMetadataTest + { + [Test] + public void TestSliderStartupMetadataCalculated() + { + var beatmap = new OsuBeatmap + { + HitObjects = + { + new Slider + { + StartTime = 0, + RepeatCount = 2, + Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, new Vector2(200, 0) }), + }, + new Slider + { + StartTime = 1500, + RepeatCount = 4, + Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, new Vector2(400, 0) }), + }, + } + }; + + foreach (var hitObject in beatmap.HitObjects) + hitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty, CancellationToken.None); + + var info = new BeatmapInfo(); + info.UpdateStatisticsFromBeatmap(beatmap); + + Assert.That(info.MaxSliderRepeats, Is.EqualTo(4)); + Assert.That(info.MaxSliderTicks, Is.GreaterThan(0)); + Assert.That(beatmap.HitObjects.OfType().Any(s => s.NestedHitObjects.OfType().Any()), Is.True); + } + } +} diff --git a/osu.Game.Tests/Beatmaps/PlayableBeatmapCacheTest.cs b/osu.Game.Tests/Beatmaps/PlayableBeatmapCacheTest.cs new file mode 100644 index 000000000000..192d0da5d47f --- /dev/null +++ b/osu.Game.Tests/Beatmaps/PlayableBeatmapCacheTest.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Beatmaps +{ + [TestFixture] + public class PlayableBeatmapCacheTest + { + [Test] + public void TestReturnsClone() + { + var cache = new PlayableBeatmapCache(); + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Hash = "hash" + } + }; + + cache.CachePlayableBeatmap(beatmap.BeatmapInfo, beatmap.BeatmapInfo.Ruleset, Array.Empty(), beatmap); + + Assert.That(cache.TryGetPlayableBeatmap(beatmap.BeatmapInfo, beatmap.BeatmapInfo.Ruleset, Array.Empty(), out var retrieved), Is.True); + Assert.That(retrieved, Is.Not.SameAs(beatmap)); + } + + [Test] + public void TestHashChangeMissesCache() + { + var cache = new PlayableBeatmapCache(); + var beatmapInfo = new BeatmapInfo + { + Hash = "hash-a" + }; + + var beatmap = new Beatmap { BeatmapInfo = beatmapInfo }; + + cache.CachePlayableBeatmap(beatmapInfo, beatmapInfo.Ruleset, Array.Empty(), beatmap); + Assert.That(cache.TryGetPlayableBeatmap(beatmapInfo, beatmapInfo.Ruleset, Array.Empty(), out _), Is.True); + + beatmapInfo.Hash = "hash-b"; + Assert.That(cache.TryGetPlayableBeatmap(beatmapInfo, beatmapInfo.Ruleset, Array.Empty(), out _), Is.False); + } + } +} diff --git a/osu.Game.Tournament/Models/TournamentBeatmap.cs b/osu.Game.Tournament/Models/TournamentBeatmap.cs index 3fafc60d0de7..44419ec477aa 100644 --- a/osu.Game.Tournament/Models/TournamentBeatmap.cs +++ b/osu.Game.Tournament/Models/TournamentBeatmap.cs @@ -28,6 +28,8 @@ public class TournamentBeatmap : IBeatmapInfo, IBeatmapSetOnlineInfo public int MaxSliderRepeats { get; set; } + public int MaxSliderTicks { get; set; } + public IBeatmapMetadataInfo Metadata { get; set; } = new BeatmapMetadata(); public IBeatmapDifficultyInfo Difficulty { get; set; } = new BeatmapDifficulty(); @@ -52,6 +54,8 @@ public TournamentBeatmap(APIBeatmap beatmap) Covers = beatmap.BeatmapSet?.Covers ?? new BeatmapSetOnlineCovers(); EndTimeObjectCount = beatmap.EndTimeObjectCount; TotalObjectCount = beatmap.TotalObjectCount; + MaxSliderRepeats = beatmap.MaxSliderRepeats; + MaxSliderTicks = beatmap.MaxSliderTicks; Ruleset = beatmap.Ruleset; } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index a45326e0ce70..c311be88a78c 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -129,6 +129,13 @@ public BeatmapOnlineStatus Status /// public int MaxSliderRepeats { get; set; } = -1; + /// + /// The maximum number of slider ticks found on a single slider in the beatmap. + /// Only relevant for rulesets that expose slider ticks (currently osu!). + /// Defaults to -1 (meaning not-yet-calculated). + /// + public int MaxSliderTicks { get; set; } = -1; + /// /// Reset any fetched online linking information (and history). /// diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index 46a118108713..8c782c764300 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -30,6 +30,14 @@ public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBe .Select(h => h.RepeatCount) .DefaultIfEmpty(0) .Max(); + + // Slider tick objects are generated as nested hit objects after defaults have been applied. + // This method is called both with raw and playable beatmaps; for raw beatmaps this will + // naturally become 0 and later be backfilled by background processing. + beatmapInfo.MaxSliderTicks = beatmap.HitObjects + .Select(h => h.NestedHitObjects.Count(n => n.GetType().Name == "SliderTick")) + .DefaultIfEmpty(0) + .Max(); } /// diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 58c9572158cc..2bde9933ce2c 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.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 System.Threading; using System.Threading.Tasks; using osu.Framework.Extensions.ObjectExtensions; @@ -10,7 +9,6 @@ using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; -using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Beatmaps { @@ -59,9 +57,10 @@ public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); var calculator = ruleset.CreateDifficultyCalculator(working); + var playable = working.GetPlayableBeatmap(working.BeatmapInfo.Ruleset); beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.UpdateStatisticsFromBeatmap(working.Beatmap); + beatmap.UpdateStatisticsFromBeatmap(playable); } // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. @@ -77,15 +76,9 @@ public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope loo workingBeatmapCache.Invalidate(beatmapInfo); var working = workingBeatmapCache.GetWorkingBeatmap(beatmapInfo); - var beatmap = working.Beatmap; - - beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); - beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; - beatmapInfo.MaxSliderRepeats = beatmap.HitObjects - .OfType() - .Select(h => h.RepeatCount) - .DefaultIfEmpty(0) - .Max(); + var playable = working.GetPlayableBeatmap(beatmapInfo.Ruleset); + + beatmapInfo.UpdateStatisticsFromBeatmap(playable); // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. workingBeatmapCache.Invalidate(beatmapInfo); diff --git a/osu.Game/Beatmaps/IBeatmapInfo.cs b/osu.Game/Beatmaps/IBeatmapInfo.cs index 5e363ae3a203..9b1efa8d3df0 100644 --- a/osu.Game/Beatmaps/IBeatmapInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapInfo.cs @@ -84,5 +84,12 @@ public interface IBeatmapInfo : IHasOnlineID, IEquatable /// Defaults to -1 (meaning not-yet-calculated). /// int MaxSliderRepeats { get; } + + /// + /// The maximum number of slider ticks on a single slider in the beatmap. + /// Only meaningful for rulesets that have slider ticks (e.g. osu!). + /// Defaults to -1 (meaning not-yet-calculated). + /// + int MaxSliderTicks { get; } } } diff --git a/osu.Game/Beatmaps/PlayableBeatmapCache.cs b/osu.Game/Beatmaps/PlayableBeatmapCache.cs index 425aef3c37b7..3331058b76fb 100644 --- a/osu.Game/Beatmaps/PlayableBeatmapCache.cs +++ b/osu.Game/Beatmaps/PlayableBeatmapCache.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Logging; +using osu.Game; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -21,7 +22,7 @@ namespace osu.Game.Beatmaps /// /// /// The cache stores one canonical (immutable) copy keyed by - /// (BeatmapInfo.ID, rulesetShortName, orderedModsKey). + /// (BeatmapInfo.ID + BeatmapInfo.Hash, rulesetShortName, orderedModsKey, gameVersion). /// Callers receive a shallow so that gameplay state does not /// bleed across sessions while still reusing the pre-built hit-object graph. /// @@ -33,20 +34,23 @@ namespace osu.Game.Beatmaps /// public class PlayableBeatmapCache : Component { - private readonly record struct CacheKey(Guid BeatmapId, string RulesetShortName, string ModsKey); + private readonly record struct CacheKey(Guid BeatmapId, string BeatmapHash, string RulesetShortName, string ModsKey, string GameVersion); private readonly Dictionary cache = new Dictionary(); private WorkingBeatmapCache? workingBeatmapCache; + private string gameVersion = string.Empty; - [BackgroundDependencyLoader] - private void load(IWorkingBeatmapCache beatmapCache) + [BackgroundDependencyLoader(true)] + private void load(IWorkingBeatmapCache beatmapCache, OsuGameBase? game) { if (beatmapCache is WorkingBeatmapCache concrete) { workingBeatmapCache = concrete; workingBeatmapCache.OnInvalidated += handleInvalidated; } + + gameVersion = game?.VersionHash ?? typeof(OsuGameBase).Assembly.GetName().Version?.ToString() ?? "unknown"; } /// @@ -110,7 +114,7 @@ private void handleInvalidated(WorkingBeatmap working) } } - private static CacheKey makeKey(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList mods) + private CacheKey makeKey(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList mods) { // Build a deterministic key from ordered mod acronyms and their settings hash. // Mod.GetHashCode() accounts for both the type and any user-adjustable settings. @@ -118,7 +122,7 @@ private static CacheKey makeKey(BeatmapInfo beatmapInfo, IRulesetInfo ruleset, I .OrderBy(m => m.Acronym) .Select(m => $"{m.Acronym}:{m.GetHashCode()}")); - return new CacheKey(beatmapInfo.ID, ruleset.ShortName, modsKey); + return new CacheKey(beatmapInfo.ID, beatmapInfo.Hash, ruleset.ShortName, modsKey, gameVersion); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index e46035081552..2ff84cada8f3 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -295,7 +295,7 @@ private void processBeatmapsWithMissingObjectCounts() realmAccess.Run(r => { - foreach (var b in r.All().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0 || b.MaxSliderRepeats < 0)) + foreach (var b in r.All().Where(b => b.TotalObjectCount < 0 || b.EndTimeObjectCount < 0 || b.MaxSliderRepeats < 0 || b.MaxSliderTicks < 0)) beatmapIds.Add(b.ID); }); diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index adf553032620..68d508f1b516 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -102,8 +102,9 @@ public class RealmAccess : IDisposable /// 50 2025-07-11 Add UserTags to BeatmapMetadata. /// 51 2025-07-22 Add ScoreInfo.Pauses. /// 52 2025-??-?? Add MaxSliderRepeats to BeatmapInfo for osu! pool sizing metadata. + /// 53 2025-??-?? Add MaxSliderTicks to BeatmapInfo for osu! pool sizing metadata. /// - private const int schema_version = 52; + private const int schema_version = 53; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -1353,6 +1354,14 @@ void remapKeyBinding(int oldAction, int newAction) beatmap.MaxSliderRepeats = -1; break; + + case 53: + // New MaxSliderTicks field on BeatmapInfo for osu! pool sizing. + // Set all existing entries to -1 so BackgroundDataStoreProcessor can backfill them. + foreach (var beatmap in migration.NewRealm.All()) + beatmap.MaxSliderTicks = -1; + + break; } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index a16be65b78a5..d4e9794653f1 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -53,6 +53,9 @@ public class APIBeatmap : IBeatmapInfo, IBeatmapOnlineInfo // Not provided by the online API; callers that need this for pool sizing should fall back to an in-memory scan. public int MaxSliderRepeats => 0; + // Not provided by the online API; callers that need this for pool sizing should fall back to an in-memory scan. + public int MaxSliderTicks => 0; + [JsonProperty(@"drain")] public float DrainRate { get; set; } diff --git a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs index 9ff0dc9c65c9..8724fb307280 100644 --- a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs +++ b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs @@ -56,6 +56,11 @@ public class LegacyBeatmapConversionDifficultyInfo : IBeatmapDifficultyInfo /// public int MaxSliderRepeats { get; set; } + /// + /// The maximum number of slider ticks on a single slider in the beatmap. + /// + public int MaxSliderTicks { get; set; } + double IBeatmapDifficultyInfo.SliderMultiplier => 0; double IBeatmapDifficultyInfo.SliderTickRate => 0; @@ -69,7 +74,9 @@ public class LegacyBeatmapConversionDifficultyInfo : IBeatmapDifficultyInfo CircleSize = beatmap.Difficulty.CircleSize, OverallDifficulty = beatmap.Difficulty.OverallDifficulty, EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration), - TotalObjectCount = beatmap.HitObjects.Count + TotalObjectCount = beatmap.HitObjects.Count, + MaxSliderRepeats = beatmap.HitObjects.OfType().Select(h => h.RepeatCount).DefaultIfEmpty(0).Max(), + MaxSliderTicks = beatmap.HitObjects.Select(h => h.NestedHitObjects.Count(n => n.GetType().Name == "SliderTick")).DefaultIfEmpty(0).Max() }; public static LegacyBeatmapConversionDifficultyInfo FromBeatmapInfo(IBeatmapInfo beatmapInfo) => new LegacyBeatmapConversionDifficultyInfo @@ -80,7 +87,9 @@ public class LegacyBeatmapConversionDifficultyInfo : IBeatmapDifficultyInfo CircleSize = beatmapInfo.Difficulty.CircleSize, OverallDifficulty = beatmapInfo.Difficulty.OverallDifficulty, EndTimeObjectCount = beatmapInfo.EndTimeObjectCount, - TotalObjectCount = beatmapInfo.TotalObjectCount + TotalObjectCount = beatmapInfo.TotalObjectCount, + MaxSliderRepeats = beatmapInfo.MaxSliderRepeats, + MaxSliderTicks = beatmapInfo.MaxSliderTicks }; } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index d2ed92c4aefd..4cb52a9cf7c6 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -3,6 +3,8 @@ using System; using System.Diagnostics; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using ManagedBass.Fx; using osu.Framework.Allocation; @@ -168,6 +170,7 @@ protected bool BackgroundBrightnessReduction protected bool QuickRestart { get; private set; } private IDisposable? highPerformanceSession; + private CancellationTokenSource? playablePrewarmCancellation; [Resolved] private INotificationOverlay? notificationOverlay { get; set; } @@ -178,6 +181,9 @@ protected bool BackgroundBrightnessReduction [Resolved] private AudioManager audioManager { get; set; } = null!; + [Resolved(canBeNull: true)] + private PlayableBeatmapCache? playableBeatmapCache { get; set; } + [Resolved] private BatteryInfo? batteryInfo { get; set; } @@ -564,6 +570,7 @@ private void prepareForRestart(bool quickRestartRequested) private void contentIn(double delayBeforeSideDisplays = 0) { MetadataInfo.Loading = true; + beginPlayableBeatmapPrewarm(); if (QuickRestart) { @@ -720,6 +727,58 @@ private void cancelLoad() { scheduledPushPlayer?.Cancel(); scheduledPushPlayer = null; + cancelPlayableBeatmapPrewarm(); + } + + private void beginPlayableBeatmapPrewarm() + { + cancelPlayableBeatmapPrewarm(); + + if (playableBeatmapCache == null || Beatmap.Value is DummyWorkingBeatmap) + return; + + var workingBeatmap = Beatmap.Value; + var beatmapInfo = workingBeatmap.BeatmapInfo; + var rulesetInfo = Ruleset.Value; + var gameplayMods = Mods.Value.Select(m => m.DeepClone()).ToArray(); + + playablePrewarmCancellation = new CancellationTokenSource(); + var token = playablePrewarmCancellation.Token; + + Task.Run(() => + { + if (token.IsCancellationRequested) + return; + + var ruleset = rulesetInfo.CreateInstance(); + + if (ruleset == null) + return; + + if (playableBeatmapCache.TryGetPlayableBeatmap(beatmapInfo, ruleset.RulesetInfo, gameplayMods, out _)) + return; + + var playable = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods, token); + + if (token.IsCancellationRequested || playable.HitObjects.Count == 0) + return; + + playableBeatmapCache.CachePlayableBeatmap(beatmapInfo, ruleset.RulesetInfo, gameplayMods, playable); + }, token).ContinueWith(t => + { + if (t.IsCanceled || t.Exception?.InnerException is OperationCanceledException) + return; + + if (t.Exception != null) + Logger.Error(t.Exception.InnerException ?? t.Exception, "Playable beatmap prewarm failed."); + }, TaskScheduler.Default); + } + + private void cancelPlayableBeatmapPrewarm() + { + playablePrewarmCancellation?.Cancel(); + playablePrewarmCancellation?.Dispose(); + playablePrewarmCancellation = null; } private void endHighPerformance() @@ -742,6 +801,8 @@ protected override void Dispose(bool isDisposing) DisposalTask = LoadTask?.ContinueWith(_ => CurrentPlayer?.Dispose()); } + cancelPlayableBeatmapPrewarm(); + // This is only a failsafe; should be disposed more immediately by `endHighPerformance` call. highPerformanceSession?.Dispose(); } From 6cfec93972f980010a112298c3d061f689aef0e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:15:04 +0000 Subject: [PATCH 05/19] Fix double GetPlayableBeatmap in BeatmapUpdater; start prewarm in LoadComplete; fix test import Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/cef46462-51dc-44c0-bfcc-44fa8a66c144 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Beatmaps/BeatmapInfoStartupMetadataTest.cs | 1 + osu.Game/Beatmaps/BeatmapUpdater.cs | 13 +++++++++++-- osu.Game/Screens/Play/PlayerLoader.cs | 12 +++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs b/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs index 2aad6af55507..cec809bd24c4 100644 --- a/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs +++ b/osu.Game.Tests/Beatmaps/BeatmapInfoStartupMetadataTest.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 2bde9933ce2c..6238e9702f88 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -57,10 +57,19 @@ public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); var calculator = ruleset.CreateDifficultyCalculator(working); - var playable = working.GetPlayableBeatmap(working.BeatmapInfo.Ruleset); beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.UpdateStatisticsFromBeatmap(playable); + + // Use the raw decoded beatmap for basic statistics that do not require ApplyDefaults. + // DifficultyCalculator.Calculate() already calls GetPlayableBeatmap() internally, so + // calling it again here would double the cost of beatmap conversion during import. + beatmap.UpdateStatisticsFromBeatmap(working.Beatmap); + + // MaxSliderTicks requires nested hit objects that are only populated after ApplyDefaults + // (i.e. a full GetPlayableBeatmap() pass). Reset to -1 so that + // BackgroundDataStoreProcessor will schedule a ProcessObjectCounts call that computes + // the accurate value without duplicating the conversion work done above. + beatmap.MaxSliderTicks = -1; } // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 4cb52a9cf7c6..e634f72e6dc9 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -340,6 +340,12 @@ protected override void LoadComplete() // - the sort mode is not specified and defaults to `Score` which is good because gameplay leaderboards only support sorting by score. // this may change at some point in the future, at which point specifying a sort mode should be considered. refetchLeaderboard(force: false); + + // Begin playable beatmap pre-computation as early as possible. + // PlayerLoader is often loaded asynchronously in the background while the previous screen is + // still showing, so starting here gives the background task the maximum available lead time + // before the Player itself needs the result. + beginPlayableBeatmapPrewarm(); } private void refetchLeaderboard(bool force) @@ -732,7 +738,11 @@ private void cancelLoad() private void beginPlayableBeatmapPrewarm() { - cancelPlayableBeatmapPrewarm(); + // Don't restart if a prewarm is already running — LoadComplete() and contentIn() both call + // this method; the one that fires second (contentIn) would otherwise cancel work that is + // potentially already done or nearly done. + if (playablePrewarmCancellation != null) + return; if (playableBeatmapCache == null || Beatmap.Value is DummyWorkingBeatmap) return; From 172e66e8bcc73a759aa867678a479e0c297e510b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:23:01 +0000 Subject: [PATCH 06/19] Gameplay perf: ISliderTick interface, hoist PositionAt out of repeat loop, cache AuxiliarySamples, fix accuracy string allocs Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/cef46462-51dc-44c0-bfcc-44fa8a66c144 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Objects/Drawables/DrawableSlider.cs | 14 ++++++++++++-- osu.Game.Rulesets.Osu/Objects/Slider.cs | 8 +++++++- osu.Game.Rulesets.Osu/Objects/SliderTick.cs | 3 ++- osu.Game/Beatmaps/BeatmapInfoExtensions.cs | 2 +- osu.Game/Rulesets/Objects/Types/ISliderTick.cs | 17 +++++++++++++++++ .../LegacyBeatmapConversionDifficultyInfo.cs | 2 +- .../Screens/Play/HUD/ArgonAccuracyCounter.cs | 13 ++++++++++--- osu.Game/Screens/Play/PlayerLoader.cs | 4 ++++ 8 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 osu.Game/Rulesets/Objects/Types/ISliderTick.cs diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 05b33684d4d1..e0eb9a081b8d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -266,8 +266,18 @@ protected override void UpdateAfterChildren() Ball.UpdateProgress(completionProgress); SliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0); - foreach (DrawableSliderRepeat repeat in repeatContainer) - repeat.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0)); + // Pre-compute the snaking start/end path positions once. + // PositionAt() performs a binary search over the pre-built path points list, + // so recomputing it for every repeat on every frame is wasteful — all repeats + // share the same start/end values within a single update. + if (repeatContainer.Count > 0) + { + Vector2 snakeStart = HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0); + Vector2 snakeEnd = HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0); + + foreach (DrawableSliderRepeat repeat in repeatContainer) + repeat.UpdateSnakingPosition(snakeStart, snakeEnd); + } Size = SliderBody?.Size ?? Vector2.Zero; OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero; diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index c40ce95de861..609a0dae625a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -34,7 +34,10 @@ public double Duration set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. } - public override IList AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray(); + public override IList AuxiliarySamples => cachedAuxiliarySamples ??= CreateSlidingSamples().Concat(TailSamples).ToArray(); + + // Cached after ApplyDefaults populates TailSamples — stable for the lifetime of this slider instance. + private IList? cachedAuxiliarySamples; private readonly Cached endPositionCache = new Cached(); @@ -165,6 +168,9 @@ protected override void CreateNestedHitObjects(CancellationToken cancellationTok { base.CreateNestedHitObjects(cancellationToken); + // Invalidate the auxiliary-samples cache since TailSamples will be reassigned below. + cachedAuxiliarySamples = null; + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken); foreach (var e in sliderEvents) diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index 219c2be00b69..82b064a40c3c 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -4,12 +4,13 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - public class SliderTick : OsuHitObject + public class SliderTick : OsuHitObject, ISliderTick { public int SpanIndex { get; set; } public double SpanStartTime { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index 8c782c764300..da28fe8e8472 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -35,7 +35,7 @@ public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBe // This method is called both with raw and playable beatmaps; for raw beatmaps this will // naturally become 0 and later be backfilled by background processing. beatmapInfo.MaxSliderTicks = beatmap.HitObjects - .Select(h => h.NestedHitObjects.Count(n => n.GetType().Name == "SliderTick")) + .Select(h => h.NestedHitObjects.Count(n => n is ISliderTick)) .DefaultIfEmpty(0) .Max(); } diff --git a/osu.Game/Rulesets/Objects/Types/ISliderTick.cs b/osu.Game/Rulesets/Objects/Types/ISliderTick.cs new file mode 100644 index 000000000000..705f301ba1fe --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/ISliderTick.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// Marks a nested as a tick-type sub-component + /// of a slider (i.e. generated by ). + /// + /// + /// This interface exists so that core-game code (e.g. pool-sizing metadata) can count slider ticks + /// without taking a compile-time dependency on any ruleset assembly. + /// + public interface ISliderTick + { + } +} diff --git a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs index 8724fb307280..a824fb3ea2ab 100644 --- a/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs +++ b/osu.Game/Rulesets/Scoring/Legacy/LegacyBeatmapConversionDifficultyInfo.cs @@ -76,7 +76,7 @@ public class LegacyBeatmapConversionDifficultyInfo : IBeatmapDifficultyInfo EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration), TotalObjectCount = beatmap.HitObjects.Count, MaxSliderRepeats = beatmap.HitObjects.OfType().Select(h => h.RepeatCount).DefaultIfEmpty(0).Max(), - MaxSliderTicks = beatmap.HitObjects.Select(h => h.NestedHitObjects.Count(n => n.GetType().Name == "SliderTick")).DefaultIfEmpty(0).Max() + MaxSliderTicks = beatmap.HitObjects.Select(h => h.NestedHitObjects.Count(n => n is ISliderTick)).DefaultIfEmpty(0).Max() }; public static LegacyBeatmapConversionDifficultyInfo FromBeatmapInfo(IBeatmapInfo beatmapInfo) => new LegacyBeatmapConversionDifficultyInfo diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index 7bdda068de01..e39b0f306474 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -53,10 +53,17 @@ public LocalisableString Text get => wholePart.Text; set { - string[] split = value.ToString().Replace("%", string.Empty).Split("."); + // Avoid Replace + Split which allocates a string copy, a string array, and two element strings + // on every roll tick during gameplay. IndexOf-based slicing halves the allocation count. + string str = value.ToString(); + int dotIndex = str.IndexOf('.'); + int pctIndex = str.IndexOf('%'); - wholePart.Text = split[0]; - fractionPart.Text = "." + split[1]; + if (dotIndex >= 0 && pctIndex > dotIndex) + { + wholePart.Text = str[..dotIndex]; + fractionPart.Text = str[dotIndex..pctIndex]; + } } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index e634f72e6dc9..d82b49e0139b 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -576,6 +576,10 @@ private void prepareForRestart(bool quickRestartRequested) private void contentIn(double delayBeforeSideDisplays = 0) { MetadataInfo.Loading = true; + + // On initial entry this is already running from LoadComplete(), so the call is a no-op. + // On retry (OnResuming → cancelLoad → contentIn) cancelLoad() has cleared the token, + // so this correctly restarts the prewarm for the same beatmap/mod combination. beginPlayableBeatmapPrewarm(); if (QuickRestart) From 0388d4a5ab0358856bbf9b9ca5a838285d87b77f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:46:47 +0000 Subject: [PATCH 07/19] 5 gameplay hot-path optimizations: snaking threshold, slider ball cache, playfield mods cache, cursor trail version gate, HitObjectContainer incremental sort Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/cef46462-51dc-44c0-bfcc-44fa8a66c144 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../Objects/Drawables/DrawableSliderBall.cs | 27 ++++++++--- .../Skinning/SnakingSliderBody.cs | 12 ++++- .../UI/Cursor/CursorTrail.cs | 25 ++++++++++- osu.Game/Rulesets/UI/HitObjectContainer.cs | 45 +++++++++++-------- osu.Game/Rulesets/UI/Playfield.cs | 14 +++--- 5 files changed, 92 insertions(+), 31 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 4daf87de1c2d..4c378b65db30 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -61,17 +61,34 @@ public override void ApplyTransformsAt(double time, bool propagateChildren = fal base.ApplyTransformsAt(time, false); } + private double cachedPathDistance = -1; + private double cachedCheckDistance; + public void UpdateProgress(double completionProgress) { Slider slider = drawableSlider.HitObject; + + // Cache the check-distance; Path.Distance is stable after ApplyDefaults so the + // division only runs once per slider-pool reuse (when the path changes). + double pathDistance = slider.Path.Distance; + + if (pathDistance != cachedPathDistance) + { + cachedPathDistance = pathDistance; + cachedCheckDistance = 0.1 / pathDistance; + } + + // Exact position at current progress (binary search #1). Position = slider.CurvePositionAt(completionProgress); - // 0.1 / slider.Path.Distance is the additional progress needed to ensure the diff length is 0.1 - double checkDistance = 0.1 / slider.Path.Distance; - var diff = slider.CurvePositionAt(Math.Min(1 - checkDistance, completionProgress)) - slider.CurvePositionAt(Math.Min(1, completionProgress + checkDistance)); + // Forward-tangent point for ball rotation (binary search #2). + // Using (current → forward) instead of the original symmetric + // (backward → forward) cuts one PositionAt call per frame with + // imperceptible accuracy loss, since checkDistance is tiny. + double dForward = Math.Min(1, completionProgress + cachedCheckDistance); + var diff = Position - slider.CurvePositionAt(dForward); - // Ensure the value is substantially high enough to allow for Atan2 to get a valid angle. - // Needed for when near completion, or in case of a very short slider. + // Ensure the diff is long enough for Atan2 to return a meaningful angle. if (diff.LengthSquared() < 0.0001f) return; diff --git a/osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs index cd38e7a22479..1411b727b558 100644 --- a/osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs @@ -141,12 +141,22 @@ private void updatePathSize() Path.Size = Size; } + // Minimum progress-change needed before we rebuild the path mesh. + // 0.002 = 0.2% of path length; sub-pixel for any slider ≥ 50px. + // At ≤240fps the per-frame change always exceeds this, so there is + // zero visual impact at normal frame rates. At 500fps+ it halves + // (or better) the number of GetPathToProgress + SetVertices calls. + private const double snaking_update_threshold = 0.002; + private void setRange(double p0, double p1) { if (p0 > p1) (p0, p1) = (p1, p0); - if (SnakedStart == p0 && SnakedEnd == p1) return; + if (SnakedStart.HasValue && SnakedEnd.HasValue + && Math.Abs(p0 - SnakedStart.Value) < snaking_update_threshold + && Math.Abs(p1 - SnakedEnd.Value) < snaking_update_threshold) + return; SnakedStart = p0; SnakedEnd = p1; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index cf31adbb76e1..ae6723a8ff47 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; using System.Numerics; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -75,6 +76,11 @@ protected Anchor TrailOrigin private readonly TrailPart[] parts = new TrailPart[max_sprites]; private int currentIndex; + + // Incremented (via Interlocked) every time a new trail part is written. + // The draw node reads this to decide whether a full CopyTo is necessary; + // on frames where the cursor hasn't moved no copy is performed. + private int partsVersion; private IShader shader; private double timeOffset; private float time; @@ -221,6 +227,10 @@ private void addPart(Vector2 localSpacePosition) ++parts[currentIndex].InvalidationID; currentIndex = (currentIndex + 1) % max_sprites; + + // Memory barrier: ensure the parts[] write above is visible to the draw thread + // before the version increment so ApplyState always sees a consistent snapshot. + Interlocked.Increment(ref partsVersion); } protected override DrawNode CreateDrawNode() => new TrailDrawNode(this); @@ -248,6 +258,10 @@ private class TrailDrawNode : DrawNode private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 originPosition; + // Tracks the last partsVersion we copied from the source. + // CopyTo (≈56 KB) is skipped on frames where no new parts were added. + private int lastAppliedPartsVersion = -1; + private IVertexBatch vertexBatch; public TrailDrawNode(CursorTrail source) @@ -278,7 +292,16 @@ public override void ApplyState() else if (Source.TrailOrigin.HasFlag(Anchor.y2)) originPosition.Y = 1f; - Source.parts.CopyTo(parts, 0); + // Only copy the parts array when new trail parts have been written since + // the last ApplyState. The Interlocked.Increment in addPart() provides a + // full memory barrier so the array content is always consistent here. + int currentVersion = Volatile.Read(ref Source.partsVersion); + + if (currentVersion != lastAppliedPartsVersion) + { + Source.parts.CopyTo(parts, 0); + lastAppliedPartsVersion = currentVersion; + } } private IUniformBuffer cursorTrailParameters; diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 61e3ed2c090e..7dd15ce876d9 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -38,28 +38,16 @@ public partial class HitObjectContainer : PooledDrawableWithLifetimeContainer AliveObjects => getSortedAliveObjects(); private readonly List aliveObjectsSortedCache = new List(); - private bool aliveObjectsCacheDirty = true; - private IEnumerable enumerateByStartTimeAscending() - { - var children = InternalChildren; - - for (int i = children.Count - 1; i >= 0; i--) - { - if (children[i] is DrawableHitObject hitObject) - yield return hitObject; - } - } + // Set only when a start-time bindable fires (extremely rare: editor only). + // Normal add/remove uses incremental insertion which never sets this flag. + private bool aliveObjectsCacheDirty; private IEnumerable getSortedAliveObjects() { + // Re-sort only if a StartTime bindable changed (editor scenario). 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; } @@ -149,7 +137,23 @@ protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHit private void addDrawable(DrawableHitObject drawable) { - aliveObjectsCacheDirty = true; + // Binary-search insertion to keep aliveObjectsSortedCache in StartTime order. + // O(log n) search + O(n) shift — far cheaper than rebuilding & sorting + // the entire list from scratch on every alive-state transition. + double startTime = drawable.HitObject.StartTime; + int lo = 0, hi = aliveObjectsSortedCache.Count; + + while (lo < hi) + { + int mid = (lo + hi) >> 1; + + if (aliveObjectsSortedCache[mid].HitObject.StartTime <= startTime) + lo = mid + 1; + else + hi = mid; + } + + aliveObjectsSortedCache.Insert(lo, drawable); drawable.OnNewResult += onNewResult; @@ -159,7 +163,8 @@ private void addDrawable(DrawableHitObject drawable) private void removeDrawable(DrawableHitObject drawable) { - aliveObjectsCacheDirty = true; + // Linear removal is acceptable; alive object counts are small (typically 5–30). + aliveObjectsSortedCache.Remove(drawable); drawable.OnNewResult -= onNewResult; @@ -205,7 +210,11 @@ private void bindStartTime(DrawableHitObject hitObject) bindable.BindValueChanged(_ => { if (LoadState >= LoadState.Ready) + { SortInternal(); + // StartTime changed: incremental order is no longer valid; re-sort on next access. + aliveObjectsCacheDirty = true; + } }); startTimeMap[hitObject] = bindable; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index bbd22a30e7ff..0052949761eb 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -248,6 +248,7 @@ protected void AddNested(Playfield otherPlayfield) } private Mod[] mods; + private IUpdatableByPlayfield[] updatableByPlayfieldMods = Array.Empty(); protected override void LoadComplete() { @@ -255,6 +256,10 @@ protected override void LoadComplete() mods = Mods?.ToArray(); + // Pre-filter once; avoids a per-frame `is IUpdatableByPlayfield` check on every mod. + updatableByPlayfieldMods = mods?.OfType().ToArray() + ?? Array.Empty(); + // in the case a consumer forgets to add the HitObjectContainer, we will add it here. if (HitObjectContainer.Parent == null) AddInternal(HitObjectContainer); @@ -264,13 +269,10 @@ protected override void Update() { base.Update(); - if (!IsNested && mods != null) + if (!IsNested) { - foreach (Mod mod in mods) - { - if (mod is IUpdatableByPlayfield updatable) - updatable.Update(this); - } + foreach (var updatable in updatableByPlayfieldMods) + updatable.Update(this); } // When rewinding, revert future judgements in the reverse order. From 30c49d10228f6636b5fa849166bbca5da3c33139 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:48:41 +0000 Subject: [PATCH 08/19] Address code-review: ArgonAccuracyCounter format fallback, Playfield mods-cache lifetime comment, HitObjectContainer tie-breaker note Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/cef46462-51dc-44c0-bfcc-44fa8a66c144 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Game/Rulesets/UI/HitObjectContainer.cs | 5 +++++ osu.Game/Rulesets/UI/Playfield.cs | 2 ++ osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 7dd15ce876d9..f93122914d7e 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -140,6 +140,11 @@ private void addDrawable(DrawableHitObject drawable) // Binary-search insertion to keep aliveObjectsSortedCache in StartTime order. // O(log n) search + O(n) shift — far cheaper than rebuilding & sorting // the entire list from scratch on every alive-state transition. + // Note: `<=` means new objects with the same StartTime are appended after + // existing ones (stable insertion order within a tie group). The full + // visual-tree Compare also applies CompareReverseChildID as a tie-breaker, + // but AliveObjects consumers (e.g. cursor particles) don't require that + // level of ordering stability. double startTime = drawable.HitObject.StartTime; int lo = 0, hi = aliveObjectsSortedCache.Count; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 0052949761eb..82fff6af29ea 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -257,6 +257,8 @@ protected override void LoadComplete() mods = Mods?.ToArray(); // Pre-filter once; avoids a per-frame `is IUpdatableByPlayfield` check on every mod. + // Mods are resolved once at construction and don't change during gameplay, so this + // cached slice is always valid for the lifetime of this Playfield. updatableByPlayfieldMods = mods?.OfType().ToArray() ?? Array.Empty(); diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index e39b0f306474..af3e40cbc40f 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -64,6 +64,12 @@ public LocalisableString Text wholePart.Text = str[..dotIndex]; fractionPart.Text = str[dotIndex..pctIndex]; } + else + { + // Fallback for unexpected format (e.g. localised strings without '.' or '%'). + wholePart.Text = str; + fractionPart.Text = string.Empty; + } } } From 9f4834f520d5b2e1f6d66a53665e397bbef5db91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 19:15:09 +0000 Subject: [PATCH 09/19] Fix CI errors: Logger import, enumerateByStartTimeAscending, PlayableBeatmapCache partial/null, Player nullable, bump framework 2026.521.1 Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/b24e3f10-427e-4d67-ade0-890e1f5955a4 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- osu.Android.props | 2 +- osu.Game/Beatmaps/PlayableBeatmapCache.cs | 6 ++---- osu.Game/Rulesets/UI/HitObjectContainer.cs | 8 ++++++++ osu.Game/Screens/Play/Player.cs | 2 +- osu.Game/Screens/Play/PlayerLoader.cs | 1 + osu.Game/osu.Game.csproj | 4 ++-- osu.iOS.props | 2 +- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 0c12d083739c..82798c8fbb0d 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,7 +52,7 @@ - +