From 6997f89698f2866bd7049f04e1f40e788b35a5cc Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 28 Jan 2026 22:27:07 +0900 Subject: [PATCH 1/5] Add assertion that we always get the up-to-date beatmap file in a `WorkingBeatmap` --- osu.Game/Beatmaps/WorkingBeatmapCache.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 0bb61798dc..75f1bd2a07 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Diagnostics; using System.IO; using System.Linq; using JetBrains.Annotations; @@ -104,6 +105,10 @@ namespace osu.Game.Beatmaps beatmapInfo = beatmapInfo.Detach(); + // If this ever gets hit, a request has arrived with an outdated BeatmapInfo. + // An outdated BeatmapInfo may contain a reference to a previous version of the beatmap's files on disk. + Debug.Assert(confirmFileHashIsUpToDate(beatmapInfo), "working beatmap returned with outdated path"); + workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this)); // best effort; may be higher than expected. @@ -113,6 +118,12 @@ namespace osu.Game.Beatmaps } } + private bool confirmFileHashIsUpToDate(BeatmapInfo beatmapInfo) + { + string refetchPath = realm.Run(r => r.Find(beatmapInfo.ID)?.File?.File.Hash); + return refetchPath == null || refetchPath == beatmapInfo.File?.File.Hash; + } + #region IResourceStorageProvider TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; From 4c2ffa0f79682255e95a579cf67a796eb53a8270 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 28 Jan 2026 22:48:18 +0900 Subject: [PATCH 2/5] More aggressively cancel debounced tracked bindable updates If we've run an update since we can cancel the scheduled debounce run. --- osu.Game/Beatmaps/BeatmapDifficultyCache.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 4ef484cb67..71d34404cf 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -82,8 +82,11 @@ namespace osu.Game.Beatmaps modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); modSettingChangeTracker.SettingChanged += _ => { - debouncedModSettingsChange?.Cancel(); - debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100); + lock (bindableUpdateLock) + { + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100); + } }; }, true); } @@ -195,6 +198,9 @@ namespace osu.Game.Beatmaps { lock (bindableUpdateLock) { + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = null; + trackedUpdateCancellationSource.Cancel(); trackedUpdateCancellationSource = new CancellationTokenSource(); From 10651512a52d6a6daa6a258089c223d3fecd7dd6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 28 Jan 2026 22:48:52 +0900 Subject: [PATCH 3/5] Avoid triggering bindable updates if mods haven't actually changed --- osu.Game/Beatmaps/BeatmapDifficultyCache.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 71d34404cf..2183cf00df 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -75,6 +75,10 @@ namespace osu.Game.Beatmaps currentMods.BindValueChanged(mods => { + // A change in bindable here doesn't guarantee that mods have actually changed. + if (mods.OldValue.SequenceEqual(mods.NewValue)) + return; + modSettingChangeTracker?.Dispose(); Scheduler.AddOnce(updateTrackedBindables); From 5dd34f165fb59227cc29869ebf74d917bb35b037 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Jan 2026 00:23:48 +0900 Subject: [PATCH 4/5] Add failing test coverage --- .../TestSceneBeatmapDifficultyCache.cs | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index 7a05a3da5c..b63c0a8196 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -15,6 +16,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Visual; @@ -34,6 +36,12 @@ namespace osu.Game.Tests.Beatmaps private IBindable starDifficultyBindable; + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private BeatmapDifficultyCache actualDifficultyCache { get; set; } + [BackgroundDependencyLoader] private void load(OsuGameBase osu) { @@ -55,6 +63,36 @@ namespace osu.Game.Tests.Beatmaps AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value.Stars == BASE_STARS); } + [Test] + public void TestInvalidationFlow() + { + BeatmapInfo postEditBeatmapInfo = null; + BeatmapInfo preEditBeatmapInfo = null; + + IBindable bindableDifficulty = null; + + AddStep("get bindable stars", () => + { + preEditBeatmapInfo = importedSet.Beatmaps.First(); + bindableDifficulty = actualDifficultyCache.GetBindableDifficulty(preEditBeatmapInfo); + }); + + AddUntilStep("wait for stars retrieved", () => bindableDifficulty.Value.Stars, () => Is.GreaterThan(0)); + + AddStep("remove all hitobjects", () => + { + var working = beatmapManager.GetWorkingBeatmap(preEditBeatmapInfo); + + ((IList)working.Beatmap.HitObjects).Clear(); + + beatmapManager.Save(working.BeatmapInfo, working.Beatmap); + postEditBeatmapInfo = working.BeatmapInfo; + }); + + AddAssert("stars is now zero", () => actualDifficultyCache.GetDifficultyAsync(postEditBeatmapInfo).GetResultSafely()!.Value.Stars, () => Is.Zero); + AddUntilStep("bindable stars is now zero", () => bindableDifficulty.Value.Stars, () => Is.Zero); + } + [Test] public void TestStarDifficultyChangesOnModSettings() { @@ -122,8 +160,10 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestKeyDoesntEqualWithDifferentModSettings() { - var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }); - var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.9 } } }); + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.9 } } }); Assert.That(key1, Is.Not.EqualTo(key2)); Assert.That(key1.GetHashCode(), Is.Not.EqualTo(key2.GetHashCode())); @@ -132,8 +172,10 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestKeyEqualWithMatchingModSettings() { - var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); - var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = guid }, new RulesetInfo { OnlineID = 0 }, + new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); Assert.That(key1, Is.EqualTo(key2)); Assert.That(key1.GetHashCode(), Is.EqualTo(key2.GetHashCode())); From b705313d3f3bcd950bbb6076de995c91c82cbd18 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 28 Jan 2026 23:09:08 +0900 Subject: [PATCH 5/5] Fix `BeatmapDifficultyCache`'s bindable tracking not correctly updating `BeatmapInfo` metadata on invalidation --- osu.Game/Beatmaps/BeatmapDifficultyCache.cs | 25 ++++++++++++++++++--- osu.Game/Beatmaps/BeatmapUpdater.cs | 6 ++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 2183cf00df..291239e350 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -95,9 +95,28 @@ namespace osu.Game.Beatmaps }, true); } - public void Invalidate(IBeatmapInfo beatmap) + /// + /// Notify this cache that a beatmap has been invalidated/updated. + /// + /// The old beatmap model. + /// The updated beatmap model. + public void Invalidate(IBeatmapInfo oldBeatmap, IBeatmapInfo newBeatmap) { - base.Invalidate(lookup => lookup.BeatmapInfo.Equals(beatmap)); + base.Invalidate(lookup => lookup.BeatmapInfo.Equals(oldBeatmap)); + + lock (bindableUpdateLock) + { + bool trackedBindablesRefreshRequired = false; + + foreach (var bsd in trackedBindables.Where(bsd => bsd.BeatmapInfo.Equals(oldBeatmap))) + { + bsd.BeatmapInfo = newBeatmap; + trackedBindablesRefreshRequired = true; + } + + if (trackedBindablesRefreshRequired) + Scheduler.AddOnce(updateTrackedBindables); + } } /// @@ -358,7 +377,7 @@ namespace osu.Game.Beatmaps private class BindableStarDifficulty : Bindable { - public readonly IBeatmapInfo BeatmapInfo; + public IBeatmapInfo BeatmapInfo; public readonly CancellationToken CancellationToken; public BindableStarDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken) diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index ff23bf1242..72c69393df 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -52,11 +52,11 @@ namespace osu.Game.Beatmaps foreach (BeatmapInfo beatmap in beatmapSet.Beatmaps) { - difficultyCache.Invalidate(beatmap); - var working = workingBeatmapCache.GetWorkingBeatmap(beatmap); - var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); + difficultyCache.Invalidate(beatmap, working.BeatmapInfo); + + var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); var calculator = ruleset.CreateDifficultyCalculator(working); beatmap.StarRating = calculator.Calculate().StarRating;