From 479401e533682635868cfcedbcb2c7b15179103f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 21 Aug 2021 15:11:08 +0200 Subject: [PATCH 1/4] Add option to set own computation function in test --- .../Beatmaps/TestSceneBeatmapDifficultyCache.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index da457c9e8f..7bbe647302 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -58,6 +58,12 @@ namespace osu.Game.Tests.Beatmaps { OsuModDoubleTime dt = null; + AddStep("set computation function", () => difficultyCache.ComputeDifficulty = lookup => + { + var modRateAdjust = (ModRateAdjust)lookup.OrderedMods.SingleOrDefault(mod => mod is ModRateAdjust); + return new StarDifficulty(BASE_STARS + modRateAdjust?.SpeedChange.Value ?? 0, 0); + }); + AddStep("change selected mod to DT", () => SelectedMods.Value = new[] { dt = new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); AddUntilStep($"star difficulty -> {BASE_STARS + 1.5}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.5); @@ -133,13 +139,11 @@ namespace osu.Game.Tests.Beatmaps private class TestBeatmapDifficultyCache : BeatmapDifficultyCache { + public Func ComputeDifficulty { get; set; } + protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken token = default) { - var rateAdjust = lookup.OrderedMods.OfType().SingleOrDefault(); - if (rateAdjust != null) - return Task.FromResult(new StarDifficulty(BASE_STARS + rateAdjust.SpeedChange.Value, 0)); - - return Task.FromResult(new StarDifficulty(BASE_STARS, 0)); + return Task.FromResult(ComputeDifficulty?.Invoke(lookup) ?? new StarDifficulty(BASE_STARS, 0)); } } } From f642546d6a385be54181138ce868a3df80d28950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 21 Aug 2021 15:15:13 +0200 Subject: [PATCH 2/4] Add failing test case --- .../TestSceneBeatmapDifficultyCache.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index 7bbe647302..2b4dc48fe0 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -74,6 +74,29 @@ namespace osu.Game.Tests.Beatmaps AddUntilStep($"star difficulty -> {BASE_STARS + 1.75}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.75); } + [Test] + public void TestStarDifficultyAdjustHashCodeConflict() + { + OsuModDifficultyAdjust difficultyAdjust = null; + + AddStep("set computation function", () => difficultyCache.ComputeDifficulty = lookup => + { + var modDifficultyAdjust = (ModDifficultyAdjust)lookup.OrderedMods.SingleOrDefault(mod => mod is ModDifficultyAdjust); + return new StarDifficulty(BASE_STARS * (modDifficultyAdjust?.OverallDifficulty.Value ?? 1), 0); + }); + + AddStep("change selected mod to DA", () => SelectedMods.Value = new[] { difficultyAdjust = new OsuModDifficultyAdjust() }); + AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS); + + AddStep("change DA difficulty to 0.5", () => difficultyAdjust.OverallDifficulty.Value = 0.5f); + AddUntilStep($"star difficulty -> {BASE_STARS * 0.5f}", () => starDifficultyBindable.Value?.Stars == BASE_STARS / 2); + + // hash code of 0 (the value) conflicts with the hash code of null (the initial/default value). + // it's important that the mod reference and its underlying bindable references stay the same to demonstrate this failure. + AddStep("change DA difficulty to 0", () => difficultyAdjust.OverallDifficulty.Value = 0); + AddUntilStep($"star difficulty -> 0", () => starDifficultyBindable.Value?.Stars == 0); + } + [Test] public void TestKeyEqualsWithDifferentModInstances() { From 995338029c78d000323dd6db66303042e8bdd153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 21 Aug 2021 15:39:02 +0200 Subject: [PATCH 3/4] Fix difficulty cache lookups sharing underlying mod instances `DifficultyCacheLookup`s were storing raw `Mod` instances into their `OrderedMods` field. This could cause the cache lookups to wrongly succeed in cases of mods with settings. The particular case that triggered this fix was Difficulty Adjust. Because the difficulty cache is backed by a dictionary, there are two stages to the lookup; first `GetHashCode()` is used to find the appropriate hash bucket to look in, and then items from that hash bucket are compared against the key being searched for via the implementation of `Equals()`. As it turns out, the first hashing step ended up being the saving grace in most cases, as the hash computation included the values of the mod settings. But the Difficulty Adjust failure case was triggered by the quirk that `GetHashCode(0) == GetHashCode(null) == 0`. In such a case, the `Equals()` fallback was used. But as it turns out, because the `Mod` instance stored to lookups was not cloned and therefore potentially externally mutable, it could be polluted after being stored to the dictionary, and therefore breaking the equality check. Even though all of the setting values were compared, the hash bucket didn't match the actual contents of the lookup anymore (because they were mutated externally, e.g. by the user changing the mod setting values in the mod settings overlay). To resolve, clone out the mod structure before creating all difficulty lookups. --- osu.Game/Beatmaps/BeatmapDifficultyCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 5025e4ad4b..0aa6a6dd0b 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -310,7 +310,7 @@ namespace osu.Game.Beatmaps Beatmap = beatmap; // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. Ruleset = ruleset ?? Beatmap.Ruleset; - OrderedMods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); + OrderedMods = mods?.OrderBy(m => m.Acronym).Select(mod => mod.DeepClone()).ToArray() ?? Array.Empty(); } public bool Equals(DifficultyCacheLookup other) From 43b3845970fefeb2998a3103b7caf565583feede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 21 Aug 2021 16:35:40 +0200 Subject: [PATCH 4/4] Remove redundant string interpolation --- osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index 2b4dc48fe0..dcfeea5db8 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -94,7 +94,7 @@ namespace osu.Game.Tests.Beatmaps // hash code of 0 (the value) conflicts with the hash code of null (the initial/default value). // it's important that the mod reference and its underlying bindable references stay the same to demonstrate this failure. AddStep("change DA difficulty to 0", () => difficultyAdjust.OverallDifficulty.Value = 0); - AddUntilStep($"star difficulty -> 0", () => starDifficultyBindable.Value?.Stars == 0); + AddUntilStep("star difficulty -> 0", () => starDifficultyBindable.Value?.Stars == 0); } [Test]