From 87f323a2e533ed4f132bcbce1a3c317964d7e450 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 15 Feb 2026 23:25:46 +0900 Subject: [PATCH 1/7] 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 a2b108fc0a..34373d0fbc 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - + From 2f459dd94e8a15ae962e3c108841fbc5941f0587 Mon Sep 17 00:00:00 2001 From: Arthur Araujo <90941580+64ArthurAraujo@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:50:04 -0300 Subject: [PATCH 2/7] Preserve bookmarks when creating a new difficulty from scratch (#36675) Closes #33395 Copies the bookmarks from `referenceWorkingBeatmap` while creating a new difficulty from scratch. I adapted the tests in `TestSceneEditorBeatmapCreation` to include the bookmark checks. --------- Co-authored-by: Dean Herbert --- .../Visual/Editing/TestSceneEditorBeatmapCreation.cs | 10 ++++++++++ osu.Game/Beatmaps/BeatmapManager.cs | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 8d7eb41369..7440697ba0 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -132,6 +132,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add effect point", () => EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new HitCircle @@ -185,6 +186,7 @@ namespace osu.Game.Tests.Visual.Editing var effectPoint = EditorBeatmap.ControlPointInfo.EffectPoints.Single(); return effectPoint.Time == 500 && effectPoint.KiaiMode && effectPoint.ScrollSpeedBindable.IsDefault; }); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); @@ -223,6 +225,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString()); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("add effect points", () => { EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); @@ -253,6 +256,8 @@ namespace osu.Game.Tests.Visual.Editing return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); + AddAssert("created difficulty has effect points", () => { return EditorBeatmap.ControlPointInfo.EffectPoints.SequenceEqual(new[] @@ -284,6 +289,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("add effect points", () => { EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); @@ -311,6 +317,8 @@ namespace osu.Game.Tests.Visual.Editing return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); + AddAssert("created difficulty has effect points", () => { // since this difficulty is on another ruleset, scroll speed specifications are completely reset, @@ -344,6 +352,7 @@ namespace osu.Game.Tests.Visual.Editing StartTime = 1000 } })); + AddStep("add bookmarks", () => EditorBeatmap.Bookmarks.AddRange([500, 1000])); AddStep("set approach rate", () => EditorBeatmap.Difficulty.ApproachRate = 4); AddStep("set combo colours", () => { @@ -394,6 +403,7 @@ namespace osu.Game.Tests.Visual.Editing return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); AddAssert("created difficulty has objects", () => EditorBeatmap.HitObjects.Count == 2); + AddAssert("created difficulty has bookmarks", () => EditorBeatmap.Bookmarks.Count == 2); AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 8446f43c53..6d1dbaafba 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -154,7 +154,11 @@ namespace osu.Game.Beatmaps { DifficultyName = NamingUtils.GetNextBestName(targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), "New Difficulty") }; - var newBeatmap = new Beatmap { BeatmapInfo = newBeatmapInfo }; + var newBeatmap = new Beatmap + { + BeatmapInfo = newBeatmapInfo, + Bookmarks = referenceWorkingBeatmap.Beatmap.Bookmarks.ToArray() + }; foreach (var timingPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.TimingPoints) newBeatmap.ControlPointInfo.Add(timingPoint.Time, timingPoint.DeepClone()); From 810edebe87d074e6ed4d0560ee94ae006956155d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 16 Feb 2026 11:02:59 +0100 Subject: [PATCH 3/7] Fix extra lives in Easy mod potentially getting reapplied during gameplay (#36678) Closes https://github.com/ppy/osu/issues/36676. --- .../Mods/TestSceneOsuModEasy.cs | 88 +++++++++++++++++++ .../Rulesets/Mods/ModEasyWithExtraLives.cs | 19 ++-- 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs new file mode 100644 index 0000000000..1b5d9da02b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModEasy.cs @@ -0,0 +1,88 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModEasy : OsuModTestScene + { + protected override bool AllowFail => true; + + [Test] + public void TestMultipleApplication() + { + bool reapplied = false; + CreateModTest(new ModTestData + { + Mods = [new OsuModEasy { Retries = { Value = 1 } }], + Autoplay = false, + CreateBeatmap = () => + { + // do stuff to speed up fails + var b = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + Difficulty = { DrainRate = 10 } + }; + + foreach (var ho in b.HitObjects) + ho.StartTime /= 4; + + return b; + }, + PassCondition = () => + { + if (((ModEasyTestPlayer)Player).FailuresSuppressed > 0 && !reapplied) + { + try + { + foreach (var mod in Player.GameplayState.Mods.OfType()) + mod.ApplyToDifficulty(new BeatmapDifficulty()); + + foreach (var mod in Player.GameplayState.Mods.OfType()) + mod.ApplyToPlayer(Player); + } + catch + { + // don't care if this fails. in fact a failure here is probably better than the alternative. + } + finally + { + reapplied = true; + } + } + + return Player.GameplayState.HasFailed && ((ModEasyTestPlayer)Player).FailuresSuppressed <= 1; + } + }); + } + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new ModEasyTestPlayer(CurrentTestData, AllowFail); + + private partial class ModEasyTestPlayer : ModTestPlayer + { + public int FailuresSuppressed { get; private set; } + + public ModEasyTestPlayer(ModTestData data, bool allowFail) + : base(data, allowFail) + { + } + + protected override bool CheckModsAllowFailure() + { + bool failureAllowed = GameplayState.Mods.OfType().All(m => m.PerformFail()); + + if (!failureAllowed) + FailuresSuppressed++; + + return failureAllowed; + } + } + } +} diff --git a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs index 1a2cb08a53..09086f4d86 100644 --- a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs +++ b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs @@ -3,17 +3,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Humanizer; using osu.Framework.Bindables; using osu.Framework.Localisation; -using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Mods { - public abstract class ModEasyWithExtraLives : ModEasy, IApplicableFailOverride, IApplicableToHealthProcessor + public abstract class ModEasyWithExtraLives : ModEasy, IApplicableFailOverride, IApplicableToPlayer, IApplicableToHealthProcessor { [SettingSource("Extra Lives", "Number of extra lives")] public Bindable Retries { get; } = new BindableInt(2) @@ -33,18 +34,26 @@ namespace osu.Game.Rulesets.Mods public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAccuracyChallenge)).ToArray(); - private int retries; + private int? retries; private readonly BindableNumber health = new BindableDouble(); - public override void ApplyToDifficulty(BeatmapDifficulty difficulty) + public void ApplyToPlayer(Player player) { - base.ApplyToDifficulty(difficulty); + // this throw works for two reasons: + // - every time `Player` loads, it deep-clones mods into itself, and the deep clone copies *only* `[SettingsSource]` properties + // - `Player` is the only consumer of `IApplicableToPlayer` and it calls `ApplyToPlayer()` exactly once per mod instance + // if either of the above assumptions no longer holds true for any reason, this will need to be reconsidered + if (retries != null) + throw new InvalidOperationException(@"Cannot apply this mod instance to a player twice."); + retries = Retries.Value; } public bool PerformFail() { + Debug.Assert(retries != null); + if (retries == 0) return true; health.Value = health.MaxValue; From 8e26cf4e1b8f2bd09e0e69f9f290cef906598480 Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Mon, 16 Feb 2026 16:40:29 +0100 Subject: [PATCH 4/7] Restore previous beatmap when leaving scoped mode (#36582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #36288. If the current selection is still available after leaving scoped mode, it's left as is. If it's not, the selection from before entering scoped mode is restored. https://github.com/user-attachments/assets/b1ac3de1-7c7f-4949-82a9-1dd0459f3f61 --------- Co-authored-by: Bartłomiej Dach --- .../TestSceneSongSelectFiltering.cs | 192 ++++++++++++++++-- .../FilterControl.ScopedBeatmapSetDisplay.cs | 19 +- osu.Game/Screens/SelectV2/FilterControl.cs | 31 +-- osu.Game/Screens/SelectV2/ISongSelect.cs | 17 +- .../SelectV2/PanelBeatmapSet.SpreadDisplay.cs | 9 +- .../PanelBeatmapStandalone.SpreadDisplay.cs | 9 +- osu.Game/Screens/SelectV2/SongSelect.cs | 25 ++- 7 files changed, 248 insertions(+), 54 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs index 0e195b4742..2e7e08b331 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs @@ -12,6 +12,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -266,6 +267,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); AddAssert("still has selection", () => Beatmap.IsDefault, () => Is.False); + + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0)); } [Test] @@ -365,13 +368,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SortBy(SortMode.Difficulty); checkMatchedBeatmaps(6); - AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); - AddStep("click spread indicator", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single(d => d.Enabled.Value)); - InputManager.Click(MouseButton.Left); - }); - WaitForFiltering(); + scopeBeatmap(false); checkMatchedBeatmaps(3); AddStep("press Escape", () => InputManager.Key(Key.Escape)); @@ -389,9 +386,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SortBy(SortMode.Artist); checkMatchedBeatmaps(6); - AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); - AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); - WaitForFiltering(); + scopeBeatmap(true); checkMatchedBeatmaps(3); AddStep("press Escape", () => InputManager.Key(Key.Escape)); @@ -413,9 +408,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 WaitForFiltering(); checkMatchedBeatmaps(3); - AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); - AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); - WaitForFiltering(); + scopeBeatmap(true); checkMatchedBeatmaps(3); AddStep("press Escape", () => InputManager.Key(Key.Escape)); @@ -424,6 +417,179 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("text filter not emptied", () => filterTextBox.Current.Value, () => Is.Not.Empty); } + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeRevertsToOriginalSelection(bool grouped) + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("select normal difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Normal"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Normal"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("exit scoped view", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + WaitForFiltering(); + + checkMatchedBeatmaps(6); + AddAssert("normal difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Normal"))); + + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeWhenSelectedBeatmapHiddenByFilters(bool grouped) + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("set star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, findBeatmap("Hard").StarRating + 0.1)); + WaitForFiltering(); + + AddStep("select hard difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Hard"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("exit scoped view", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + WaitForFiltering(); + + AddAssert("hard difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeByChangingRuleset(bool grouped) + { + bool showConverts = Config.Get(OsuSetting.ShowConvertedBeatmaps); + + AddStep("hide converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + ImportBeatmapForRuleset(0, 2); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(2); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(2); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("change ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); + WaitForFiltering(); + + AddAssert("hard catch difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + AddStep("revert convert setting", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, showConverts)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeByShowingConverts(bool grouped) + { + bool showConverts = Config.Get(OsuSetting.ShowConvertedBeatmaps); + + AddStep("hide converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("set star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, Beatmap.Value.BeatmapSetInfo.Beatmaps.ElementAt(1).StarRating + 0.1)); + WaitForFiltering(); + + AddStep("select hard difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Hard"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("select insane difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Insane"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Insane"))); + + AddStep("show converts", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + WaitForFiltering(); + + AddAssert("hard difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + AddStep("revert convert setting", () => Config.SetValue(OsuSetting.ShowConvertedBeatmaps, showConverts)); + AddStep("reset star difficulty filter", () => Config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestUnscopeByChangingFilterText(bool grouped) + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(grouped ? SortMode.Title : SortMode.Difficulty); + checkMatchedBeatmaps(6); + + AddStep("select hard difficulty", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(findBeatmap("Hard"))); + AddUntilStep("selection changed", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Hard"))); + + scopeBeatmap(grouped); + checkMatchedBeatmaps(3); + + AddStep("set filter text", () => filterTextBox.Current.Value = findBeatmap("Normal").DifficultyName); + WaitForFiltering(); + + AddAssert("normal difficulty is selected", () => Beatmap.Value.BeatmapInfo, () => Is.EqualTo(findBeatmap("Normal"))); + } + + private void scopeBeatmap(bool grouped) + { + if (grouped) + { + AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); + AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); + } + else + { + AddUntilStep("wait for spread indicator", () => this.ChildrenOfType().Any(d => d.Enabled.Value)); + AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); + } + + WaitForFiltering(); + } + + private BeatmapInfo findBeatmap(string difficultySubstring) => Beatmap.Value.BeatmapSetInfo.Beatmaps.First(b => b.DifficultyName.Contains(difficultySubstring)); + private NoResultsPlaceholder? getPlaceholder() => SongSelect.ChildrenOfType().FirstOrDefault(); private void checkMatchedBeatmaps(int expected) => AddUntilStep($"{expected} matching shown", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); diff --git a/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs b/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs index 650ef4a1a9..378db5cb2d 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs @@ -22,13 +22,8 @@ namespace osu.Game.Screens.SelectV2 { public partial class ScopedBeatmapSetDisplay : OsuClickableContainer, IKeyBindingHandler { - public Bindable ScopedBeatmapSet - { - get => scopedBeatmapSet.Current; - set => scopedBeatmapSet.Current = value; - } + public IBindable ScopedBeatmapSet { get; } = new Bindable(); - private readonly BindableWithCurrent scopedBeatmapSet = new BindableWithCurrent(); private Box flashLayer = null!; private Container content = null!; private OsuTextFlowContainer text = null!; @@ -44,7 +39,7 @@ namespace osu.Game.Screens.SelectV2 } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(ISongSelect? songSelect, OverlayColourProvider colourProvider) { Content.AutoSizeEasing = Easing.OutQuint; Content.AutoSizeDuration = transition_duration; @@ -97,25 +92,25 @@ namespace osu.Game.Screens.SelectV2 Alpha = 0, }, }); - Action = () => scopedBeatmapSet.Value = null; + Action = () => songSelect?.UnscopeBeatmapSet(); } protected override void LoadComplete() { base.LoadComplete(); - scopedBeatmapSet.BindValueChanged(_ => updateState(), true); + ScopedBeatmapSet.BindValueChanged(_ => updateState(), true); } private void updateState() { - if (scopedBeatmapSet.Value != null) + if (ScopedBeatmapSet.Value != null) { content.BypassAutoSizeAxes = Axes.None; text.Clear(); text.AddText(SongSelectStrings.TemporarilyShowingAllBeatmapsIn); text.AddText(@" "); - text.AddText(scopedBeatmapSet.Value.Metadata.GetDisplayTitleRomanisable(), t => t.Font = OsuFont.Style.Body.With(weight: FontWeight.Bold)); + text.AddText(ScopedBeatmapSet.Value.Metadata.GetDisplayTitleRomanisable(), t => t.Font = OsuFont.Style.Body.With(weight: FontWeight.Bold)); } else { @@ -126,7 +121,7 @@ namespace osu.Game.Screens.SelectV2 public bool OnPressed(KeyBindingPressEvent e) { - if (scopedBeatmapSet.Value != null && e.Action == GlobalAction.Back && !e.Repeat) + if (ScopedBeatmapSet.Value != null && e.Action == GlobalAction.Back && !e.Repeat) { TriggerClick(); return true; diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 268e937167..be2d472e6b 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.SelectV2 private const float corner_radius = 10; - public Bindable ScopedBeatmapSet { get; } = new Bindable(); + public IBindable ScopedBeatmapSet { get; } = new Bindable(); private SongSelectSearchTextBox searchTextBox = null!; private ShearedToggleButton showConvertedBeatmapsButton = null!; @@ -48,6 +48,9 @@ namespace osu.Game.Screens.SelectV2 private ShearedDropdown groupDropdown = null!; private CollectionDropdown collectionDropdown = null!; + [Resolved] + private ISongSelect? songSelect { get; set; } + [Resolved] private IBindable ruleset { get; set; } = null!; @@ -115,7 +118,7 @@ namespace osu.Game.Screens.SelectV2 { RelativeSizeAxes = Axes.X, HoldFocus = true, - ScopedBeatmapSet = ScopedBeatmapSet, + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, }, }, new GridContainer @@ -190,7 +193,7 @@ namespace osu.Game.Screens.SelectV2 }, new ScopedBeatmapSetDisplay { - ScopedBeatmapSet = ScopedBeatmapSet, + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, } }, } @@ -298,7 +301,7 @@ namespace osu.Game.Screens.SelectV2 { if (clearScopedSet && ScopedBeatmapSet.Value != null) { - ScopedBeatmapSet.Value = null; + songSelect?.UnscopeBeatmapSet(); // because `ScopedBeatmapSet` has a value change callback bound to it that calls `updateCriteria()` again, // we can just do nothing other than clear it to avoid extra work and duplicated `CriteriaChanged` invocations return; @@ -331,34 +334,22 @@ namespace osu.Game.Screens.SelectV2 internal partial class SongSelectSearchTextBox : ShearedFilterTextBox { - public Bindable ScopedBeatmapSet - { - get => scopedBeatmapSet.Current; - set => scopedBeatmapSet.Current = value; - } - - private readonly BindableWithCurrent scopedBeatmapSet = new BindableWithCurrent(); + public IBindable ScopedBeatmapSet { get; } = new Bindable(); protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox { - ScopedBeatmapSet = ScopedBeatmapSet, + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, }; private partial class InnerTextBox : InnerFilterTextBox { - public Bindable ScopedBeatmapSet - { - get => scopedBeatmapSet.Current; - set => scopedBeatmapSet.Current = value; - } - - private readonly BindableWithCurrent scopedBeatmapSet = new BindableWithCurrent(); + public IBindable ScopedBeatmapSet { get; } = new Bindable(); public override bool HandleLeftRightArrows => false; public override bool OnPressed(KeyBindingPressEvent e) { - if (e.Action == GlobalAction.Back && scopedBeatmapSet.Value != null) + if (e.Action == GlobalAction.Back && ScopedBeatmapSet.Value != null) return false; return base.OnPressed(e); diff --git a/osu.Game/Screens/SelectV2/ISongSelect.cs b/osu.Game/Screens/SelectV2/ISongSelect.cs index 6280e4048a..5de1d4e75e 100644 --- a/osu.Game/Screens/SelectV2/ISongSelect.cs +++ b/osu.Game/Screens/SelectV2/ISongSelect.cs @@ -48,8 +48,21 @@ namespace osu.Game.Screens.SelectV2 IEnumerable GetForwardActions(BeatmapInfo beatmap); /// - /// Set this to a non- value in order to temporarily bypass filter and show all difficulties of the given beatmap set. + /// Temporarily bypasses filters and shows all difficulties of the given beatmapset. /// - Bindable ScopedBeatmapSet { get; } + /// The beatmapset. + void ScopeToBeatmapSet(BeatmapSetInfo beatmapSet); + + /// + /// Removes the beatmapset scope and reverts the previously selected filters. + /// + void UnscopeBeatmapSet(); + + /// + /// Contains the currently scoped beatmapset. Used by external consumers for displaying its state. + /// Cannot be used to change the value, any changes must be done through + /// or . + /// + IBindable ScopedBeatmapSet { get; } } } diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs index db34630c48..08ea270e1f 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs @@ -34,11 +34,14 @@ namespace osu.Game.Screens.SelectV2 protected override Colour4 DimColour => Colour4.White; - private readonly Bindable scopedBeatmapSet = new Bindable(); + private readonly IBindable scopedBeatmapSet = new Bindable(); private readonly Bindable showConvertedBeatmaps = new Bindable(); private const double transition_duration = 200; + [Resolved] + private ISongSelect? songSelect { get; set; } + [Resolved] private Bindable ruleset { get; set; } = null!; @@ -59,7 +62,7 @@ namespace osu.Game.Screens.SelectV2 } [BackgroundDependencyLoader] - private void load(ISongSelect? songSelect, OsuConfigManager configManager) + private void load(OsuConfigManager configManager) { Add(new FillFlowContainer { @@ -201,7 +204,7 @@ namespace osu.Game.Screens.SelectV2 } } - Action = () => scopedBeatmapSet.Value = BeatmapSet.Value; + Action = () => songSelect?.ScopeToBeatmapSet(BeatmapSet.Value); updateEnabled(); } diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs index b6b0bac077..f8f5883c99 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs @@ -29,11 +29,14 @@ namespace osu.Game.Screens.SelectV2 protected override Colour4 DimColour => Colour4.White; - private readonly Bindable scopedBeatmapSet = new Bindable(); + private readonly IBindable scopedBeatmapSet = new Bindable(); private readonly Bindable showConvertedBeatmaps = new Bindable(); private const double transition_duration = 200; + [Resolved] + private ISongSelect? songSelect { get; set; } + [Resolved] private Bindable ruleset { get; set; } = null!; @@ -56,12 +59,12 @@ namespace osu.Game.Screens.SelectV2 Action = () => { if (Beatmap.Value != null) - scopedBeatmapSet.Value = Beatmap.Value.BeatmapSet!; + songSelect?.ScopeToBeatmapSet(Beatmap.Value.BeatmapSet!); }; } [BackgroundDependencyLoader] - private void load(ISongSelect? songSelect, OsuConfigManager configManager) + private void load(OsuConfigManager configManager) { Add(new FillFlowContainer { diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index ecf8b3301b..00590336ce 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -284,6 +284,7 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.TopRight, Origin = Anchor.TopRight, RelativeSizeAxes = Axes.X, + ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, }, } }, @@ -1242,7 +1243,29 @@ namespace osu.Game.Screens.SelectV2 beatmaps.Restore(b); } - public Bindable ScopedBeatmapSet => filterControl.ScopedBeatmapSet; + private GroupedBeatmap? beforeScopedSelection; + + private readonly Bindable scopedBeatmapSet = new Bindable(); + public IBindable ScopedBeatmapSet => scopedBeatmapSet; + + public void ScopeToBeatmapSet(BeatmapSetInfo beatmapSet) + { + beforeScopedSelection = carousel.CurrentGroupedBeatmap; + + scopedBeatmapSet.Value = beatmapSet; + } + + public void UnscopeBeatmapSet() + { + if (scopedBeatmapSet.Value == null) + return; + + if (beforeScopedSelection != null) + queueBeatmapSelection(beforeScopedSelection); + + scopedBeatmapSet.Value = null; + beforeScopedSelection = null; + } #endregion From fc4e29793d86b969f6b3e57e68ff550f3856cc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 16 Feb 2026 16:46:30 +0100 Subject: [PATCH 5/7] Use directory of last-selected sample as initial directory in sample set add controls (#36680) Closes https://github.com/ppy/osu/issues/36470. Applied directly to relevant control for now. --- osu.Game/Screens/Edit/Components/FormSampleSet.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Components/FormSampleSet.cs b/osu.Game/Screens/Edit/Components/FormSampleSet.cs index 2d3404a513..370d36dd8c 100644 --- a/osu.Game/Screens/Edit/Components/FormSampleSet.cs +++ b/osu.Game/Screens/Edit/Components/FormSampleSet.cs @@ -46,6 +46,7 @@ namespace osu.Game.Screens.Edit.Components private readonly BindableWithCurrent current = new BindableWithCurrent(); private readonly Dictionary<(string name, string bank), SampleButton> buttons = new Dictionary<(string, string), SampleButton>(); + private readonly Bindable lastSelectedFileDirectory = new Bindable(); private FormControlBackground background = null!; private FormFieldCaption caption = null!; @@ -125,6 +126,7 @@ namespace osu.Game.Screens.Edit.Components Margin = new MarginPadding(5), SampleAddRequested = SampleAddRequested, SampleRemoveRequested = SampleRemoveRequested, + LastSelectedFileDirectory = { BindTarget = lastSelectedFileDirectory }, }; protected override void LoadComplete() @@ -197,6 +199,7 @@ namespace osu.Game.Screens.Edit.Components public Action? SampleRemoveRequested { get; init; } private Bindable selectedFile { get; } = new Bindable(); + public Bindable LastSelectedFileDirectory { get; } = new Bindable(); private TrianglesV2? triangles { get; set; } @@ -313,6 +316,7 @@ namespace osu.Game.Screens.Edit.Components this.HidePopover(); ActualFilename.Value = SampleAddRequested?.Invoke(selectedFile.Value, ExpectedFilename.Value) ?? selectedFile.Value.ToString(); + LastSelectedFileDirectory.Value = selectedFile.Value.Directory; } private void deleteSample() @@ -324,7 +328,9 @@ namespace osu.Game.Screens.Edit.Components ActualFilename.Value = null; } - public Popover? GetPopover() => ActualFilename.Value == null ? new FormFileSelector.FileChooserPopover(SupportedExtensions.AUDIO_EXTENSIONS, selectedFile, null) : null; + public Popover? GetPopover() => ActualFilename.Value == null + ? new FormFileSelector.FileChooserPopover(SupportedExtensions.AUDIO_EXTENSIONS, selectedFile, LastSelectedFileDirectory.Value?.FullName) + : null; public MenuItem[]? ContextMenuItems => ActualFilename.Value != null From 42bf25442b9f2440eef9e6e6f58efdefdccd49bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 16 Feb 2026 16:50:16 +0100 Subject: [PATCH 6/7] Fix osu! editor composer scale handles not updating correctly when selected object is changed (#36681) Before: https://github.com/user-attachments/assets/d0a0373d-2d46-48a9-9ea5-bac82a612f32 After: https://github.com/user-attachments/assets/f5785b54-d7fc-4ce0-86b0-60c96ff22bc3 --- Closes https://github.com/ppy/osu/issues/36677. Kinda shocking this went by unnoticed for this long. --- .../Edit/OsuSelectionScaleHandler.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index d5f3137769..17f32ea67d 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; @@ -37,13 +38,16 @@ namespace osu.Game.Rulesets.Osu.Edit [Resolved] private IEditorChangeHandler? changeHandler { get; set; } + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + [Resolved(CanBeNull = true)] private IDistanceSnapProvider? snapProvider { get; set; } private BindableList selectedItems { get; } = new BindableList(); [BackgroundDependencyLoader] - private void load(EditorBeatmap editorBeatmap) + private void load() { selectedItems.BindTo(editorBeatmap.SelectedHitObjects); } @@ -53,15 +57,22 @@ namespace osu.Game.Rulesets.Osu.Edit base.LoadComplete(); selectedItems.CollectionChanged += (_, __) => updateState(); + editorBeatmap.HitObjectUpdated += hitObjectUpdated; updateState(); } + private void hitObjectUpdated(HitObject hitObject) + { + if (selectedMovableObjects.Contains(hitObject)) + updateState(); + } + private void updateState() { var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects); - CanScaleX.Value = quad.Width > 0; - CanScaleY.Value = quad.Height > 0; + CanScaleX.Value = Precision.DefinitelyBigger(quad.Width, 0); + CanScaleY.Value = Precision.DefinitelyBigger(quad.Height, 0); CanScaleDiagonally.Value = CanScaleX.Value && CanScaleY.Value; CanScaleFromPlayfieldOrigin.Value = selectedMovableObjects.Any(); IsScalingSlider.Value = selectedMovableObjects.Count() == 1 && selectedMovableObjects.First() is Slider; @@ -339,5 +350,13 @@ namespace osu.Game.Rulesets.Osu.Edit PathControlPointTypes = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Type).ToArray(); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorBeatmap.IsNotNull()) + editorBeatmap.HitObjectUpdated -= hitObjectUpdated; + } } } From 94cec71502cc1a3f91cabaf89551d00cc360f823 Mon Sep 17 00:00:00 2001 From: Vanni <103026931+imvanni@users.noreply.github.com> Date: Tue, 17 Feb 2026 06:49:59 +0100 Subject: [PATCH 7/7] Prevent tagging beatmaps when played with conversion mods (#36684) Closes #36553. Supersedes #36614. As discussed in #36614, the only exception to this is `ModClassic`. --- osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 5c5c814c5b..55e029f041 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -21,6 +21,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Placeholders; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics.User; using osuTK; @@ -258,6 +259,8 @@ namespace osu.Game.Screens.Ranking.Statistics preventTaggingReason = "Play the beatmap in its original ruleset to contribute to beatmap tags!"; else if (localUserScore.Rank < ScoreRank.C) preventTaggingReason = "Set a better score to contribute to beatmap tags!"; + else if (localUserScore.Mods.Any(m => (m.Type == ModType.Conversion) && !(m is ModClassic))) + preventTaggingReason = "Play this beatmap without conversion mods to contribute to beatmap tags!"; if (preventTaggingReason == null) {