From e484cf4553aaefa0f778bbbfdff8c84a730a26e5 Mon Sep 17 00:00:00 2001 From: Kenny Lorin Date: Thu, 11 Jun 2026 13:21:42 +0200 Subject: [PATCH] Use song title as tie-breaker before date added when sorting song select (#36971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit
Greetings Hello! This is my first PR for `osu!`, I hope you'll welcome me with as much enthusiasm as I have opening this! The following PR comes from some frustration on my end when using song select. I have not found issues or opened/closed PRs that address this topic, so I thought I'd shoot my shot! To give some more context, I've been pretty inactive on `osu!` and playing almost exclusively songs I imported from stable. From what I understand, this makes all my beatmapsets have the same `DateAdded`. This triggers the `ID` fallback comparison, which, as noted in the code comments, is essentially random. Writing this, I realize that I haven't checked if this addresses a behavior that changes from stable to lazer, or something that was always here!
This PR aims at making a small part of song-select sorting more intuitive by using the song's title as a fallback sorting method. My feeling is that `DateAdded` "looks more random" than the title, especially in cases like mine where the `DateAdded` fallback-comparison outputs `0`. The implementation is the same as `Artist` with `Title` fallback.
Screenshots On my installed osu version: sorting by `BPM` puts "Asymetry" in the middle of two mapsets of "Snow halation" image After the patch, mapsets with the same BPM will also be sorted by song title, for example at 173 BPM: image And further below: image
--------- Co-authored-by: Kenny Lorin Co-authored-by: Bartłomiej Dach --- .../BeatmapCarouselFilterSortingTest.cs | 36 +++++++++++++++++++ .../TestSceneBeatmapCarouselNoGrouping.cs | 9 ++--- .../Select/BeatmapCarouselFilterSorting.cs | 7 ++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs index d6729d2141..857fa2b887 100644 --- a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselFilterSortingTest.cs @@ -90,6 +90,42 @@ namespace osu.Game.Tests.Visual.SongSelect Assert.That(results.TakeWhile(b => b.BeatmapSet!.DateSubmitted != null).Count(), Is.EqualTo(30), () => "non-missing dates should be at the start"); } + [Test] + public async Task TestSortByBpmUsesTitleAsTiebreaker() + { + List beatmapSets = []; + + // 2 sets with same BPM but different titles + const int diff_count = 1; + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + set.DateAdded = new DateTimeOffset(2025, 6, 11, 10, 0, 0, TimeSpan.Zero); + set.Beatmaps.ForEach(b => + { + b.ID = Guid.Parse("00000000-0000-0000-0000-000000000000"); + b.BPM = 175; + b.Metadata.Title = "ZZZ"; + }); + beatmapSets.Add(set); + } + { + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); + set.DateAdded = new DateTimeOffset(2025, 6, 10, 10, 0, 0, TimeSpan.Zero); + set.Beatmaps.ForEach(b => + { + b.ID = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + b.BPM = 175; + b.Metadata.Title = "AAA"; + }); + beatmapSets.Add(set); + } + + var results = (await runSorting(SortMode.BPM, beatmapSets)).ToList(); + + Assert.That(results[0].Metadata.Title, Is.EqualTo("AAA")); + Assert.That(results[1].Metadata.Title, Is.EqualTo("ZZZ")); + } + [Test] public async Task TestSortByArtistUsesTitleAsTiebreaker() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs index 1ff87712de..ae3d920289 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselNoGrouping.cs @@ -323,15 +323,16 @@ namespace osu.Game.Tests.Visual.SongSelect SelectNextSet(); // both sets have a difficulty with 0.00* star rating. - // in the case of a tie when sorting, the first tie-breaker is `DateAdded` descending, which will pick the last set added (see `TestResources.CreateTestBeatmapSetInfo()`). - WaitForSetSelection(1, 0); + // in the case of a tie when sorting, the first tie-breaker is `Title` ascending, which will pick the first set added as the title contains the set ID + // (see `TestResources.CreateTestBeatmapSetInfo()`). + WaitForSetSelection(0, 0); SelectNextSet(); - WaitForSetSelection(0, 0); + WaitForSetSelection(1, 0); SelectNextPanel(); Select(); - WaitForSetSelection(1, 1); + WaitForSetSelection(0, 1); } [Test] diff --git a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs index 60e94299b2..5aa3698e9e 100644 --- a/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/Select/BeatmapCarouselFilterSorting.cs @@ -115,12 +115,15 @@ namespace osu.Game.Screens.Select throw new ArgumentOutOfRangeException(); } - // If the initial sort could not differentiate, attempt to use DateAdded to order sets in a stable fashion. + // If the initial sort could not differentiate, attempt to use Title then DateAdded to order sets in a stable fashion. + if (comparison == 0) + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(a.BeatmapSet!.Metadata.Title, b.BeatmapSet!.Metadata.Title); + // The directionality of this matches the current SortMode.DateAdded, but we may want to reconsider if that becomes a user decision (ie. asc / desc). if (comparison == 0) comparison = b.BeatmapSet!.DateAdded.CompareTo(a.BeatmapSet!.DateAdded); - // If DateAdded fails to break the tie, fallback to our internal GUID for stability. + // If both failed to break the tie, fallback to our internal GUID for stability. // This basically means it's a stable random sort. if (comparison == 0) comparison = b.BeatmapSet!.ID.CompareTo(a.BeatmapSet!.ID);