From 4d09e94367ef308c031750aa1e96d1ace1ad1df4 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Tue, 10 Sep 2024 11:46:34 -0400 Subject: [PATCH 001/173] Initial implementation --- .../Database/RealmArchiveModelImporter.cs | 4 +- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index cf0625c51c..901782238c 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -188,7 +188,9 @@ namespace osu.Game.Database Directory.CreateDirectory(mountedPath); - foreach (var realmFile in model.Files) + // Detach files from the model to avoid realm contention when copying to the external location. + // This is safe as we are not modifying the model in any way. + foreach (var realmFile in model.Files.Detach()) { string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath()); string destinationPath = Path.Join(mountedPath, realmFile.Filename); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 6f7781ee9c..1e2dbf2ffd 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -11,6 +11,7 @@ using Newtonsoft.Json; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -18,6 +19,8 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Platform; using Web = osu.Game.Resources.Localisation.Web; using osu.Framework.Testing; using osu.Game.Database; @@ -58,6 +61,9 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private OsuGame? game { get; set; } + [Resolved] + private GameHost host { get; set; } = null!; + [Resolved] private SkinManager skins { get; set; } = null!; @@ -84,6 +90,7 @@ namespace osu.Game.Overlays.SkinEditor private SkinEditorChangeHandler? changeHandler; + private EditorMenuItem mountMenuItem = null!; private EditorMenuItem undoMenuItem = null!; private EditorMenuItem redoMenuItem = null!; @@ -157,6 +164,7 @@ namespace osu.Game.Overlays.SkinEditor { new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + mountMenuItem = new EditorMenuItem("Edit externally", MenuItemType.Standard, () => _ = editExternally()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, new OsuMenuItemSpacer(), new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), new OsuMenuItemSpacer(), @@ -274,6 +282,86 @@ namespace osu.Game.Overlays.SkinEditor selectedTarget.BindValueChanged(targetChanged, true); } + private ExternalEditOperation? externalEditOperation; + + private async Task editExternally() + { + var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); + + try + { + externalEditOperation = await skins.BeginExternalEditing(skin).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to initialize external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); + } + + if (externalEditOperation == null) + return; + + host.OpenFileExternally(externalEditOperation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); + + mountMenuItem.Text.Value = "Finish external edit"; + mountMenuItem.Action.Value = () => _ = finishExternalEdit(); + } + + private async Task finishExternalEdit() + { + if (externalEditOperation == null || !externalEditOperation.IsMounted) + return; + + // TODO: The cache is not being invalidated, resulting in there being no visual change after the skin is updated. I don't know how to work with the cache, so I'm leaving it like this for now. + await Task.Run(() => + { + currentSkin.Value.SkinInfo.PerformWrite(skinInfo => + { + var filesInSkin = skinInfo.Files.Select(f => f.Filename).ToHashSet(); + var filesInMounted = Directory.EnumerateFiles(externalEditOperation.MountedPath, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(externalEditOperation.MountedPath, f)).ToHashSet(); + + // Enumerate over every file in the skin. If it's not in the mounted directory, it was deleted and should be removed from the skin. + var filesToDelete = filesInSkin.Except(filesInMounted).ToList(); + + foreach (string file in filesToDelete) + { + var fileToDelete = skinInfo.Files.First(f => f.Filename == file); + skins.DeleteFile(skinInfo, fileToDelete); + } + + // Enumerate over every file in the mounted directory. If the file is not in the skin, it should be added. If it is, the hashes should be compared, and the file should be updated if necessary. + var filesToAddOrUpdate = filesInMounted.Except(filesInSkin).ToList(); + var filesToUpdate = filesInMounted.Intersect(filesInSkin).ToList(); + + foreach (string file in filesToAddOrUpdate) + { + using var stream = File.OpenRead(Path.Combine(externalEditOperation.MountedPath, file)); + skins.AddFile(skinInfo, stream, file); + } + + foreach (string newFile in filesToUpdate) + { + var existingFile = skinInfo.Files.First(f => f.Filename == newFile); + string newFileAbsolutePath = Path.Combine(externalEditOperation.MountedPath, newFile); + string? hash = File.ReadAllText(newFileAbsolutePath).ComputeSHA2Hash(); + + if (hash == existingFile.File.Hash) continue; + + using var stream = File.OpenRead(newFileAbsolutePath); + skins.AddFile(skinInfo, stream, existingFile.Filename); + } + }); + }).ConfigureAwait(false); + + try + { + Directory.Delete(externalEditOperation.MountedPath, true); + } + catch { } + + mountMenuItem.Text.Value = "Edit externally"; + mountMenuItem.Action.Value = () => _ = editExternally(); + } + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) From 9de248f5d718d58830d8bb08dafe7d05bbd73a02 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Thu, 12 Sep 2024 23:04:19 -0400 Subject: [PATCH 002/173] Fix changes not being applied instantly, Improve import performance, lay down framework for abstracting logic --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 73 +++++++++++----------- osu.Game/Skinning/SkinImporter.cs | 7 +++ 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 1e2dbf2ffd..a2f64d0a9c 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -286,6 +286,7 @@ namespace osu.Game.Overlays.SkinEditor private async Task editExternally() { + mountMenuItem.Action.Disabled = true; var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); try @@ -302,8 +303,12 @@ namespace osu.Game.Overlays.SkinEditor host.OpenFileExternally(externalEditOperation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); - mountMenuItem.Text.Value = "Finish external edit"; - mountMenuItem.Action.Value = () => _ = finishExternalEdit(); + Schedule(() => + { + mountMenuItem.Action.Disabled = false; + mountMenuItem.Text.Value = "Finish external edit"; + mountMenuItem.Action.Value = () => _ = finishExternalEdit(); + }); } private async Task finishExternalEdit() @@ -311,55 +316,51 @@ namespace osu.Game.Overlays.SkinEditor if (externalEditOperation == null || !externalEditOperation.IsMounted) return; - // TODO: The cache is not being invalidated, resulting in there being no visual change after the skin is updated. I don't know how to work with the cache, so I'm leaving it like this for now. + mountMenuItem.Action.Disabled = true; + await Task.Run(() => { currentSkin.Value.SkinInfo.PerformWrite(skinInfo => { - var filesInSkin = skinInfo.Files.Select(f => f.Filename).ToHashSet(); - var filesInMounted = Directory.EnumerateFiles(externalEditOperation.MountedPath, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(externalEditOperation.MountedPath, f)).ToHashSet(); + // Clear files in the skin + skinInfo.Files.Clear(); - // Enumerate over every file in the skin. If it's not in the mounted directory, it was deleted and should be removed from the skin. - var filesToDelete = filesInSkin.Except(filesInMounted).ToList(); + // Get all the files in the mounted directory and add them to the skin + string[] filesInMounted = Directory.EnumerateFiles(externalEditOperation.MountedPath, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(externalEditOperation.MountedPath, f)).ToArray(); - foreach (string file in filesToDelete) - { - var fileToDelete = skinInfo.Files.First(f => f.Filename == file); - skins.DeleteFile(skinInfo, fileToDelete); - } - - // Enumerate over every file in the mounted directory. If the file is not in the skin, it should be added. If it is, the hashes should be compared, and the file should be updated if necessary. - var filesToAddOrUpdate = filesInMounted.Except(filesInSkin).ToList(); - var filesToUpdate = filesInMounted.Intersect(filesInSkin).ToList(); - - foreach (string file in filesToAddOrUpdate) + foreach (string file in filesInMounted) { using var stream = File.OpenRead(Path.Combine(externalEditOperation.MountedPath, file)); + + // The GetFile call in this method is really expensive, and we are certain that the file does not exist in the skin yet. + // Consider adding a method to add a file without checking if it exists. Or add the file directly to the skin. skins.AddFile(skinInfo, stream, file); } - - foreach (string newFile in filesToUpdate) - { - var existingFile = skinInfo.Files.First(f => f.Filename == newFile); - string newFileAbsolutePath = Path.Combine(externalEditOperation.MountedPath, newFile); - string? hash = File.ReadAllText(newFileAbsolutePath).ComputeSHA2Hash(); - - if (hash == existingFile.File.Hash) continue; - - using var stream = File.OpenRead(newFileAbsolutePath); - skins.AddFile(skinInfo, stream, existingFile.Filename); - } }); + + try + { + Directory.Delete(externalEditOperation.MountedPath, true); + } + catch { } }).ConfigureAwait(false); - try + Schedule(() => { - Directory.Delete(externalEditOperation.MountedPath, true); - } - catch { } + var oldskin = currentSkin.Value; + var newSkinInfo = oldskin.SkinInfo.PerformRead(s => s); - mountMenuItem.Text.Value = "Edit externally"; - mountMenuItem.Action.Value = () => _ = editExternally(); + // Create a new skin instance to ensure the skin is reloaded + // If there's a better way to reload the skin, this should be replaced with it. + currentSkin.Value = newSkinInfo.CreateInstance(skins); + + // Dispose the old skin to ensure it's no longer used + oldskin.Dispose(); + + mountMenuItem.Action.Disabled = false; + mountMenuItem.Text.Value = "Edit externally"; + mountMenuItem.Action.Value = () => _ = editExternally(); + }); } public bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 59c7f0ba26..9d9b197ac2 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -13,6 +14,7 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Overlays.Notifications; using Realms; namespace osu.Game.Skinning @@ -43,6 +45,11 @@ namespace osu.Game.Skinning private const string unknown_creator_string = @"Unknown"; + public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) + { + throw new NotImplementedException(); + } + protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) { var skinInfoFile = model.GetFile(skin_info_file); From a20bd5cc3d3b07ec3dfb461a3b0c6b33c2a25d74 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Mon, 23 Sep 2024 09:47:33 -0400 Subject: [PATCH 003/173] Abstract out logic to SkinImporter --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 28 +------------------- osu.Game/Skinning/SkinImporter.cs | 30 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index a2f64d0a9c..56529de88d 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -318,32 +318,7 @@ namespace osu.Game.Overlays.SkinEditor mountMenuItem.Action.Disabled = true; - await Task.Run(() => - { - currentSkin.Value.SkinInfo.PerformWrite(skinInfo => - { - // Clear files in the skin - skinInfo.Files.Clear(); - - // Get all the files in the mounted directory and add them to the skin - string[] filesInMounted = Directory.EnumerateFiles(externalEditOperation.MountedPath, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(externalEditOperation.MountedPath, f)).ToArray(); - - foreach (string file in filesInMounted) - { - using var stream = File.OpenRead(Path.Combine(externalEditOperation.MountedPath, file)); - - // The GetFile call in this method is really expensive, and we are certain that the file does not exist in the skin yet. - // Consider adding a method to add a file without checking if it exists. Or add the file directly to the skin. - skins.AddFile(skinInfo, stream, file); - } - }); - - try - { - Directory.Delete(externalEditOperation.MountedPath, true); - } - catch { } - }).ConfigureAwait(false); + await externalEditOperation.Finish().ConfigureAwait(false); Schedule(() => { @@ -354,7 +329,6 @@ namespace osu.Game.Overlays.SkinEditor // If there's a better way to reload the skin, this should be replaced with it. currentSkin.Value = newSkinInfo.CreateInstance(skins); - // Dispose the old skin to ensure it's no longer used oldskin.Dispose(); mountMenuItem.Action.Disabled = false; diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 9d9b197ac2..8147287eec 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -45,9 +46,34 @@ namespace osu.Game.Skinning private const string unknown_creator_string = @"Unknown"; - public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) + /// + /// Update an existing skin with the contents of a path + /// + /// The progress notification + /// The to update the with + /// The to update + /// + public override Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) { - throw new NotImplementedException(); + var skinInfoLive = original.ToLive(Realm); + + skinInfoLive.PerformWrite(skinInfo => + { + skinInfo.Files.Clear(); + + string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray(); + + foreach (string file in filesInMountedDirectory) + { + using var stream = File.OpenRead(Path.Combine(task.Path, file)); + + // The GetFile call in this method is *really* expensive, and we are certain that the file does not exist in the skin yet. + // Consider adding a method to add a file without checking if it already exists. Or add the file directly to the skin. + modelManager.AddFile(original, stream, file); + } + }); + + return Task.FromResult(skinInfoLive)!; } protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) From b7883f18be12d304fddb57269c5bcb123e0763b2 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Fri, 11 Oct 2024 13:46:17 -0400 Subject: [PATCH 004/173] Add a toggle for checking overwriting The GetFile method in AddFile has a huge overhead, given we're doing this in a loop. Since we clear the files in the skin, we already know there won't be any existing files, so we can skip all of that logic --- osu.Game/Database/ModelManager.cs | 17 ++++++++++------- osu.Game/Skinning/SkinImporter.cs | 5 ++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs index 7a5fb5efbf..5ecf1e0080 100644 --- a/osu.Game/Database/ModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -81,16 +81,19 @@ namespace osu.Game.Database } /// - /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten. + /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten so long as is true. /// - public void AddFile(TModel item, Stream contents, string filename, Realm realm) + public void AddFile(TModel item, Stream contents, string filename, Realm realm, bool overwrite = true) { - var existing = item.GetFile(filename); - - if (existing != null) + if (overwrite) { - ReplaceFile(existing, contents, realm); - return; + var existing = item.GetFile(filename); + + if (existing != null) + { + ReplaceFile(existing, contents, realm); + return; + } } var file = realmFileStore.Add(contents, realm); diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 8147287eec..aba14efb2f 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -59,6 +59,7 @@ namespace osu.Game.Skinning skinInfoLive.PerformWrite(skinInfo => { + // Not sure if this deletes the files from the storage or just the database. skinInfo.Files.Clear(); string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray(); @@ -67,9 +68,7 @@ namespace osu.Game.Skinning { using var stream = File.OpenRead(Path.Combine(task.Path, file)); - // The GetFile call in this method is *really* expensive, and we are certain that the file does not exist in the skin yet. - // Consider adding a method to add a file without checking if it already exists. Or add the file directly to the skin. - modelManager.AddFile(original, stream, file); + modelManager.AddFile(original, stream, file, Realm.Realm, false); } }); From ee16c964dce4e3ecff95c6b8688c0673a2f5c2f5 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Fri, 11 Oct 2024 14:06:43 -0400 Subject: [PATCH 005/173] Make everything translatable --- osu.Game/Localisation/EditorStrings.cs | 10 ++++++++++ osu.Game/Overlays/SkinEditor/SkinEditor.cs | 6 +++--- osu.Game/Screens/Edit/Editor.cs | 2 +- osu.Game/Screens/Edit/ExternalEditScreen.cs | 3 ++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index bcffc18d4d..6aaab1f653 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -134,6 +134,16 @@ namespace osu.Game.Localisation /// public static LocalisableString TimelineShowTimingChanges => new TranslatableString(getKey(@"timeline_show_timing_changes"), @"Show timing changes"); + /// + /// "Edit externally" + /// + public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally"); + + /// + /// "Finish editing and import changes" + /// + public static LocalisableString FinishEditingExternally => new TranslatableString(getKey(@"Finish editing and import changes"), @"Finish editing and import changes"); + /// /// "Show ticks" /// diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 56529de88d..ef76cd0378 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -164,7 +164,7 @@ namespace osu.Game.Overlays.SkinEditor { new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, - mountMenuItem = new EditorMenuItem("Edit externally", MenuItemType.Standard, () => _ = editExternally()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + mountMenuItem = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, () => _ = editExternally()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, new OsuMenuItemSpacer(), new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), new OsuMenuItemSpacer(), @@ -306,7 +306,7 @@ namespace osu.Game.Overlays.SkinEditor Schedule(() => { mountMenuItem.Action.Disabled = false; - mountMenuItem.Text.Value = "Finish external edit"; + mountMenuItem.Text.Value = EditorStrings.FinishEditingExternally; mountMenuItem.Action.Value = () => _ = finishExternalEdit(); }); } @@ -332,7 +332,7 @@ namespace osu.Game.Overlays.SkinEditor oldskin.Dispose(); mountMenuItem.Action.Disabled = false; - mountMenuItem.Text.Value = "Edit externally"; + mountMenuItem.Text.Value = EditorStrings.EditExternally; mountMenuItem.Action.Value = () => _ = editExternally(); }); } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index e9bcd3050b..5a5716b056 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -1210,7 +1210,7 @@ namespace osu.Game.Screens.Edit saveRelatedMenuItems.AddRange(export.Items); yield return export; - var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally); + var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally); saveRelatedMenuItems.Add(externalEdit); yield return externalEdit; } diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index 8a97e3dcb2..e906d74855 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -21,6 +21,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -156,7 +157,7 @@ namespace osu.Game.Screens.Edit }, new DangerousRoundedButton { - Text = "Finish editing and import changes", + Text = EditorStrings.FinishEditingExternally, Width = 350, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, From 2b7eb5626cfb321b54ae0be7a7b9a3049a20f2c2 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Fri, 11 Oct 2024 14:14:06 -0400 Subject: [PATCH 006/173] Rename oldskin --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index ef76cd0378..3d287d04d3 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -322,14 +322,14 @@ namespace osu.Game.Overlays.SkinEditor Schedule(() => { - var oldskin = currentSkin.Value; - var newSkinInfo = oldskin.SkinInfo.PerformRead(s => s); + var oldSkin = currentSkin.Value; + var newSkinInfo = oldSkin.SkinInfo.PerformRead(s => s); // Create a new skin instance to ensure the skin is reloaded // If there's a better way to reload the skin, this should be replaced with it. currentSkin.Value = newSkinInfo.CreateInstance(skins); - oldskin.Dispose(); + oldSkin.Dispose(); mountMenuItem.Action.Disabled = false; mountMenuItem.Text.Value = EditorStrings.EditExternally; From 0dc77a70e11de02cc4257922b5a139dd88810715 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Fri, 11 Oct 2024 16:15:57 -0400 Subject: [PATCH 007/173] Revert "Add a toggle for checking overwriting" This reverts commit b7883f18be12d304fddb57269c5bcb123e0763b2. --- osu.Game/Database/ModelManager.cs | 17 +++++++---------- osu.Game/Skinning/SkinImporter.cs | 5 +++-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs index 5ecf1e0080..7a5fb5efbf 100644 --- a/osu.Game/Database/ModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -81,19 +81,16 @@ namespace osu.Game.Database } /// - /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten so long as is true. + /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten. /// - public void AddFile(TModel item, Stream contents, string filename, Realm realm, bool overwrite = true) + public void AddFile(TModel item, Stream contents, string filename, Realm realm) { - if (overwrite) - { - var existing = item.GetFile(filename); + var existing = item.GetFile(filename); - if (existing != null) - { - ReplaceFile(existing, contents, realm); - return; - } + if (existing != null) + { + ReplaceFile(existing, contents, realm); + return; } var file = realmFileStore.Add(contents, realm); diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index aba14efb2f..8147287eec 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -59,7 +59,6 @@ namespace osu.Game.Skinning skinInfoLive.PerformWrite(skinInfo => { - // Not sure if this deletes the files from the storage or just the database. skinInfo.Files.Clear(); string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray(); @@ -68,7 +67,9 @@ namespace osu.Game.Skinning { using var stream = File.OpenRead(Path.Combine(task.Path, file)); - modelManager.AddFile(original, stream, file, Realm.Realm, false); + // The GetFile call in this method is *really* expensive, and we are certain that the file does not exist in the skin yet. + // Consider adding a method to add a file without checking if it already exists. Or add the file directly to the skin. + modelManager.AddFile(original, stream, file); } }); From 3d16e45d79a8788fa3feec9b9b7777c2b101d878 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Sat, 12 Oct 2024 17:15:48 -0400 Subject: [PATCH 008/173] Remove a comment --- osu.Game/Skinning/SkinImporter.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 8147287eec..4b024f7138 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -67,8 +67,6 @@ namespace osu.Game.Skinning { using var stream = File.OpenRead(Path.Combine(task.Path, file)); - // The GetFile call in this method is *really* expensive, and we are certain that the file does not exist in the skin yet. - // Consider adding a method to add a file without checking if it already exists. Or add the file directly to the skin. modelManager.AddFile(original, stream, file); } }); From 44fe7f2a4484d92738cc27c851cd938c4d4bf771 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Sat, 12 Oct 2024 20:39:33 -0400 Subject: [PATCH 009/173] Switch to using an overlay interface for skin mounting --- osu.Game/OsuGame.cs | 1 + osu.Game/Overlays/ExternalEditOverlay.cs | 268 +++++++++++++++++++++ osu.Game/Overlays/SkinEditor/SkinEditor.cs | 62 +---- 3 files changed, 274 insertions(+), 57 deletions(-) create mode 100644 osu.Game/Overlays/ExternalEditOverlay.cs diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index dce24c6ee7..91378b2bbc 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1119,6 +1119,7 @@ namespace osu.Game loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true); + loadComponentSingleFile(new ExternalEditOverlay(), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { diff --git a/osu.Game/Overlays/ExternalEditOverlay.cs b/osu.Game/Overlays/ExternalEditOverlay.cs new file mode 100644 index 0000000000..a107392882 --- /dev/null +++ b/osu.Game/Overlays/ExternalEditOverlay.cs @@ -0,0 +1,268 @@ +// 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.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public partial class ExternalEditOverlay : OsuFocusedOverlayContainer + { + private const double transition_duration = 300; + private FillFlowContainer flow = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + private ExternalEditOperation? editOperation; + + private Bindable? skinBindable; + private SkinManager? skinManager; + + protected override bool DimMainContent => false; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + // Since we're drawing this overlay on top of another overlay (SkinEditor), the dimming effect isn't applied. So we need to add a dimming effect manually. + new Box + { + Colour = Color4.Black.Opacity(0.5f), + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Masking = true, + CornerRadius = 20, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + flow = new FillFlowContainer + { + Margin = new MarginPadding(20), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Spacing = new Vector2(15), + } + } + } + } + }; + } + + public async Task Begin(SkinInfo skinInfo, Bindable skinBindable, SkinManager skinManager) + { + Show(); + showSpinner("Mounting external skin..."); + + await Task.Delay(500).ConfigureAwait(true); + + try + { + editOperation = await skinManager.BeginExternalEditing(skinInfo).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to initialize external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); + Schedule(() => showSpinner("Export failed!")); + await Task.Delay(1000).ConfigureAwait(true); + Hide(); + } + + this.skinBindable = skinBindable; + this.skinManager = skinManager; + + Schedule(() => + { + flow.Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Skin is mounted externally", + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new OsuTextFlowContainer + { + Padding = new MarginPadding(5), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 350, + AutoSizeAxes = Axes.Y, + Text = "Any changes made to the exported folder will be imported to the game, including file additions, modifications and deletions.", + }, + new PurpleRoundedButton + { + Text = "Open folder", + Width = 350, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = openDirectory, + Enabled = { Value = false } + }, + new DangerousRoundedButton + { + Text = EditorStrings.FinishEditingExternally, + Width = 350, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = () => finish().FireAndForget(), + Enabled = { Value = false } + } + }; + }); + + Scheduler.AddDelayed(() => + { + foreach (var b in flow.ChildrenOfType()) + b.Enabled.Value = true; + openDirectory(); + }, 1000); + } + + private void openDirectory() + { + if (editOperation == null) + return; + + gameHost.OpenFileExternally(editOperation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); + } + + private async Task finish() + { + showSpinner("Cleaning up..."); + await Task.Delay(500).ConfigureAwait(true); + + try + { + await editOperation!.Finish().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to finish external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); + showSpinner("Import failed!"); + await Task.Delay(1000).ConfigureAwait(true); + Hide(); + } + + Schedule(() => + { + var oldSkin = skinBindable!.Value; + var newSkinInfo = oldSkin.SkinInfo.PerformRead(s => s); + + // Create a new skin instance to ensure the skin is reloaded + // If there's a better way to reload the skin, this should be replaced with it. + skinBindable.Value = newSkinInfo.CreateInstance(skinManager!); + + oldSkin.Dispose(); + + Hide(); + }); + } + + protected override void PopIn() + { + this.FadeIn(transition_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(transition_duration, Easing.OutQuint).Finally(_ => + { + // Set everything to a clean state + editOperation = null; + skinManager = null; + skinBindable = null; + flow.Children = Array.Empty(); + }); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.Back: + case GlobalAction.Select: + if (editOperation == null) return base.OnPressed(e); + + finish().FireAndForget(); + return true; + } + + return base.OnPressed(e); + } + + private void showSpinner(string text) + { + foreach (var b in flow.ChildrenOfType()) + b.Enabled.Value = false; + + flow.Children = new Drawable[] + { + new OsuSpriteText + { + Text = text, + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new LoadingSpinner + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + State = { Value = Visibility.Visible } + }, + }; + } + } +} diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 3d287d04d3..8010f66eaa 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -11,7 +11,6 @@ using Newtonsoft.Json; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -19,8 +18,6 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Framework.Logging; -using osu.Framework.Platform; using Web = osu.Game.Resources.Localisation.Web; using osu.Framework.Testing; using osu.Game.Database; @@ -61,9 +58,6 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private OsuGame? game { get; set; } - [Resolved] - private GameHost host { get; set; } = null!; - [Resolved] private SkinManager skins { get; set; } = null!; @@ -90,7 +84,6 @@ namespace osu.Game.Overlays.SkinEditor private SkinEditorChangeHandler? changeHandler; - private EditorMenuItem mountMenuItem = null!; private EditorMenuItem undoMenuItem = null!; private EditorMenuItem redoMenuItem = null!; @@ -109,6 +102,9 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] + private ExternalEditOverlay? externalEditOverlay { get; set; } + public SkinEditor() { } @@ -164,7 +160,7 @@ namespace osu.Game.Overlays.SkinEditor { new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, - mountMenuItem = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, () => _ = editExternally()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, () => _ = editExternally()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, new OsuMenuItemSpacer(), new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), new OsuMenuItemSpacer(), @@ -282,59 +278,11 @@ namespace osu.Game.Overlays.SkinEditor selectedTarget.BindValueChanged(targetChanged, true); } - private ExternalEditOperation? externalEditOperation; - private async Task editExternally() { - mountMenuItem.Action.Disabled = true; var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); - try - { - externalEditOperation = await skins.BeginExternalEditing(skin).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Log($"Failed to initialize external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); - } - - if (externalEditOperation == null) - return; - - host.OpenFileExternally(externalEditOperation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); - - Schedule(() => - { - mountMenuItem.Action.Disabled = false; - mountMenuItem.Text.Value = EditorStrings.FinishEditingExternally; - mountMenuItem.Action.Value = () => _ = finishExternalEdit(); - }); - } - - private async Task finishExternalEdit() - { - if (externalEditOperation == null || !externalEditOperation.IsMounted) - return; - - mountMenuItem.Action.Disabled = true; - - await externalEditOperation.Finish().ConfigureAwait(false); - - Schedule(() => - { - var oldSkin = currentSkin.Value; - var newSkinInfo = oldSkin.SkinInfo.PerformRead(s => s); - - // Create a new skin instance to ensure the skin is reloaded - // If there's a better way to reload the skin, this should be replaced with it. - currentSkin.Value = newSkinInfo.CreateInstance(skins); - - oldSkin.Dispose(); - - mountMenuItem.Action.Disabled = false; - mountMenuItem.Text.Value = EditorStrings.EditExternally; - mountMenuItem.Action.Value = () => _ = editExternally(); - }); + await externalEditOverlay!.Begin(skin, currentSkin, skins).ConfigureAwait(false); } public bool OnPressed(KeyBindingPressEvent e) From ca5a74df26e2b25ef981db277ff2c9f662b47986 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Sat, 12 Oct 2024 21:06:25 -0400 Subject: [PATCH 010/173] Switch overlay colour scheme to purple Figured this made sense since we're using purple buttons. It doesn't really seem to change anything visually though --- osu.Game/Overlays/ExternalEditOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/ExternalEditOverlay.cs b/osu.Game/Overlays/ExternalEditOverlay.cs index a107392882..7ba1517be5 100644 --- a/osu.Game/Overlays/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/ExternalEditOverlay.cs @@ -37,7 +37,7 @@ namespace osu.Game.Overlays private FillFlowContainer flow = null!; [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); [Resolved] private GameHost gameHost { get; set; } = null!; From 15c7a12174687d5bcb9d058bcbb72a35e3a42b35 Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Sun, 13 Oct 2024 19:40:06 -0400 Subject: [PATCH 011/173] Switch colour scheme to blue --- osu.Game/Overlays/ExternalEditOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/ExternalEditOverlay.cs b/osu.Game/Overlays/ExternalEditOverlay.cs index 7ba1517be5..e9b3590626 100644 --- a/osu.Game/Overlays/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/ExternalEditOverlay.cs @@ -37,7 +37,7 @@ namespace osu.Game.Overlays private FillFlowContainer flow = null!; [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); [Resolved] private GameHost gameHost { get; set; } = null!; From 165afe357f5a52050f2337e54478f87739f125fb Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Wed, 11 Dec 2024 10:19:10 -0500 Subject: [PATCH 012/173] Rename SkinInfo when it is changed in skin.ini Peppy spoke about using a shortcut and/or hashes to determine if the skin.ini is changed, and if so, then to rename the skin. In my opinion, hashing and doing numerous comparisons is probably less efficient than just syncing the SkinInfo's name during the update. This is an easy solution that does what it needs to. --- osu.Game/Skinning/SkinImporter.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 4b024f7138..087c0f0dee 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -69,6 +69,23 @@ namespace osu.Game.Skinning modelManager.AddFile(original, stream, file); } + + string skinIniPath = Path.Combine(task.Path, "skin.ini"); + + if (!File.Exists(skinIniPath)) + return; + + using (var stream = File.OpenRead(skinIniPath)) + using (var lineReader = new LineBufferedReader(stream)) + { + var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader); + + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name)) + skinInfo.Name = decodedSkinIni.SkinInfo.Name; + + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator)) + skinInfo.Creator = decodedSkinIni.SkinInfo.Creator; + } }); return Task.FromResult(skinInfoLive)!; From 7a5e613cf68484876bbf0c4f6580b29127adb83b Mon Sep 17 00:00:00 2001 From: smallketchup82 Date: Mon, 16 Dec 2024 11:33:45 -0500 Subject: [PATCH 013/173] Disallow opening settings menu when external edit ovelay is open Also disallows using the random skin keybind when the external edit overlay is open. SkinEditor should already be disabling it, but I figured we might as well add this in for redundancy --- osu.Game/OsuGame.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 39c5fd842c..fda6c553ac 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -127,6 +127,8 @@ namespace osu.Game private SkinEditorOverlay skinEditor; + private ExternalEditOverlay externalEditOverlay; + private Container overlayContent; private Container rightFloatingOverlayContent; @@ -1125,7 +1127,7 @@ namespace osu.Game loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true); - loadComponentSingleFile(new ExternalEditOverlay(), overlayContent.Add, true); + loadComponentSingleFile(externalEditOverlay = new ExternalEditOverlay(), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { @@ -1175,6 +1177,17 @@ namespace osu.Game }; } + Settings.State.ValueChanged += state => + { + if (state.NewValue == Visibility.Hidden) + return; + + if (externalEditOverlay.State.Value == Visibility.Visible) + { + Scheduler.Add(() => Settings.Hide()); + } + }; + // ensure only one of these overlays are open at once. var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; @@ -1462,7 +1475,7 @@ namespace osu.Game // Don't allow random skin selection while in the skin editor. // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path. // If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow. - if (skinEditor.State.Value == Visibility.Visible) + if (skinEditor.State.Value == Visibility.Visible || externalEditOverlay.State.Value == Visibility.Visible) return false; SkinManager.SelectRandomSkin(); From 77cf39ac0dbdfd5f88a3865b39d7516811219ede Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 9 Jun 2025 16:05:52 +0900 Subject: [PATCH 014/173] Move release stream handling to base class --- osu.Desktop/Updater/VelopackUpdateManager.cs | 32 +++++++------------- osu.Game/Updater/UpdateManager.cs | 19 ++++++++---- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/osu.Desktop/Updater/VelopackUpdateManager.cs b/osu.Desktop/Updater/VelopackUpdateManager.cs index 6f22fd5940..51744345a4 100644 --- a/osu.Desktop/Updater/VelopackUpdateManager.cs +++ b/osu.Desktop/Updater/VelopackUpdateManager.cs @@ -4,7 +4,6 @@ using System; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Game; using osu.Game.Configuration; @@ -27,36 +26,27 @@ namespace osu.Desktop.Updater [Resolved] private ILocalUserPlayInfo? localUserInfo { get; set; } - [Resolved] - private OsuConfigManager osuConfigManager { get; set; } = null!; - private bool isInGameplay => localUserInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying; - private readonly Bindable releaseStream = new Bindable(); private UpdateManager? updateManager; private UpdateInfo? pendingUpdate; + private ReleaseStream? lastReleaseStream; - protected override void LoadComplete() + protected override async Task PerformUpdateCheck() { - // Used by the base implementation. - osuConfigManager.BindWith(OsuSetting.ReleaseStream, releaseStream); - releaseStream.BindValueChanged(_ => onReleaseStreamChanged(), true); - - base.LoadComplete(); - } - - private void onReleaseStreamChanged() - { - updateManager = new UpdateManager(new GithubSource(@"https://github.com/ppy/osu", null, releaseStream.Value == ReleaseStream.Tachyon), new UpdateOptions + if (ReleaseStream.Value != lastReleaseStream) { - AllowVersionDowngrade = true, - }); + updateManager = new UpdateManager(new GithubSource(@"https://github.com/ppy/osu", null, ReleaseStream.Value == Game.Configuration.ReleaseStream.Tachyon), new UpdateOptions + { + AllowVersionDowngrade = true, + }); - Schedule(() => Task.Run(CheckForUpdateAsync)); + lastReleaseStream = ReleaseStream.Value; + } + + return await checkForUpdateAsync().ConfigureAwait(false); } - protected override async Task PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false); - private async Task checkForUpdateAsync() { // whether to check again in 30 minutes. generally only if there's an error or no update was found (yet). diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index c114e3a8d0..354a8da46b 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -39,14 +40,17 @@ namespace osu.Game.Updater [Resolved] protected INotificationOverlay Notifications { get; private set; } = null!; + protected IBindable ReleaseStream => releaseStream; + + private readonly Bindable releaseStream = new Bindable(); + private readonly object updateTaskLock = new object(); + private Task? updateCheckTask; + protected override void LoadComplete() { base.LoadComplete(); - Schedule(() => Task.Run(CheckForUpdateAsync)); - string version = game.Version; - string lastVersion = config.Get(OsuSetting.Version); if (game.IsDeployedBuild && version != lastVersion) @@ -62,11 +66,14 @@ namespace osu.Game.Updater // debug / local compilations will reset to a non-release string. // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). config.SetValue(OsuSetting.Version, version); + + config.BindWith(OsuSetting.ReleaseStream, releaseStream); + releaseStream.BindValueChanged(_ => scheduleUpdateCheck()); + + scheduleUpdateCheck(); } - private readonly object updateTaskLock = new object(); - - private Task? updateCheckTask; + private void scheduleUpdateCheck() => Schedule(() => Task.Run(CheckForUpdateAsync)); public async Task CheckForUpdateAsync() { From d7fd7a3f81de2da633e2631b6048a014814f2580 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 9 Jun 2025 16:21:12 +0900 Subject: [PATCH 015/173] Adjust packaged update manager to check release stream --- osu.Game/Updater/GitHubRelease.cs | 8 ++++++++ osu.Game/Updater/NoActionUpdateManager.cs | 15 +++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/osu.Game/Updater/GitHubRelease.cs b/osu.Game/Updater/GitHubRelease.cs index effabdbc04..9b97dc0994 100644 --- a/osu.Game/Updater/GitHubRelease.cs +++ b/osu.Game/Updater/GitHubRelease.cs @@ -3,7 +3,9 @@ #nullable disable +using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using Newtonsoft.Json; namespace osu.Game.Updater @@ -18,5 +20,11 @@ namespace osu.Game.Updater [JsonProperty("assets")] public List Assets { get; set; } + + [JsonProperty("prerelease")] + public bool Prerelease { get; set; } + + [JsonPropertyName("published_at")] + public DateTime? PublishedAt { get; set; } } } diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index f776cd67be..2318396d08 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -18,7 +16,7 @@ namespace osu.Game.Updater /// public partial class NoActionUpdateManager : UpdateManager { - private string version; + private string version = string.Empty; [BackgroundDependencyLoader] private void load(OsuGameBase game) @@ -30,11 +28,16 @@ namespace osu.Game.Updater { try { - var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); + bool includePrerelease = ReleaseStream.Value == Configuration.ReleaseStream.Tachyon; - await releases.PerformAsync().ConfigureAwait(false); + OsuJsonWebRequest releasesRequest = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases?per_page=10&page=1"); + await releasesRequest.PerformAsync().ConfigureAwait(false); - var latest = releases.ResponseObject; + GitHubRelease[] releases = releasesRequest.ResponseObject; + GitHubRelease? latest = releases.OrderByDescending(r => r.PublishedAt).FirstOrDefault(r => includePrerelease || !r.Prerelease); + + if (latest == null) + return false; // avoid any discrepancies due to build suffixes for now. // eventually we will want to support release streams and consider these. From fb0800131726ba1eb3dadf9a188fc5b8d24cd7b5 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 9 Jun 2025 16:26:39 +0900 Subject: [PATCH 016/173] Add `OSU_EXTERNAL_UPDATE_STREAM` Valid values are `Tachyon`/`Lazer`. --- osu.Game/Updater/NoActionUpdateManager.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 2318396d08..4b8a3f000f 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -1,10 +1,12 @@ // 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.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Overlays.Notifications; @@ -16,6 +18,8 @@ namespace osu.Game.Updater /// public partial class NoActionUpdateManager : UpdateManager { + private static ReleaseStream? externalReleaseStream => Enum.TryParse(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_STREAM"), out ReleaseStream stream) ? stream : null; + private string version = string.Empty; [BackgroundDependencyLoader] @@ -28,7 +32,8 @@ namespace osu.Game.Updater { try { - bool includePrerelease = ReleaseStream.Value == Configuration.ReleaseStream.Tachyon; + ReleaseStream stream = externalReleaseStream ?? ReleaseStream.Value; + bool includePrerelease = stream == Configuration.ReleaseStream.Tachyon; OsuJsonWebRequest releasesRequest = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases?per_page=10&page=1"); await releasesRequest.PerformAsync().ConfigureAwait(false); From d4fc6693b4db3b11ffb5ba0ab5ee4a1fc250bc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 10 Jun 2025 11:46:34 +0200 Subject: [PATCH 017/173] Fix stable scores importing with a `LegacyOnlineID` of 0 Closes https://github.com/ppy/osu/issues/33435. The root cause of the issue is that the user's database contained a whole lot of scores with `LegacyOnlineID` of 0, which would trip up https://github.com/ppy/osu/blob/97e6187f0d7c3dbee896596a623e34627135bf0e/osu.Game/Extensions/ModelExtensions.cs#L128-L129 as that method would thus consider scores that are not the same as the same because of the zero, which later trips up https://github.com/ppy/osu/blob/97e6187f0d7c3dbee896596a623e34627135bf0e/osu.Game/Screens/Ranking/SoloResultsScreen.cs#L79 which ends up inserting `Score` into the list several times, which causes the crash. You might remember that I tried to fix this once before in https://github.com/ppy/osu/pull/24794. What I did not realise, however, is that stable *can still produce replays* that have an online ID of zero in them, because zero *is just `default(long)`*: https://github.com/peppy/osu-stable-reference/blob/7205341bb70000a87fa1bd54e7642772e2af85d7/osu!/GameplayElements/Scoring/Score.cs#L123 https://github.com/peppy/osu-stable-reference/blob/7205341bb70000a87fa1bd54e7642772e2af85d7/osu!/GameplayElements/Scoring/Score.cs#L350 The alternative way of fixing this would be just to change `MatchesOnlineID` to reject zeroes, but I think this is a saner overall direction. --- osu.Game/Database/RealmAccess.cs | 9 ++++++++- osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 7142f2b300..49bde7c505 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -98,8 +98,9 @@ namespace osu.Game.Database /// 46 2024-12-26 Change beat snap divisor bindings to match stable directionality ¯\_(ツ)_/¯. /// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions. /// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues). + /// 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. /// - private const int schema_version = 48; + private const int schema_version = 49; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -1255,6 +1256,12 @@ namespace osu.Game.Database foreach (var beatmap in beatmaps) beatmap.ResetOnlineInfo(resetOnlineId: false); break; + + case 49: + foreach (var score in migration.NewRealm.All().Where(s => s.LegacyOnlineID == 0)) + score.LegacyOnlineID = -1; + + break; } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index a32c05c4eb..cf6819b086 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -110,6 +110,9 @@ namespace osu.Game.Scoring.Legacy else if (version >= 20121008) scoreInfo.LegacyOnlineID = sr.ReadInt32(); + if (scoreInfo.LegacyOnlineID == 0) + scoreInfo.LegacyOnlineID = -1; + byte[] compressedScoreInfo = null; if (version >= 30000001) From 26bbc3202aa18580b2324b3e7455578c8f75b620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 10 Jun 2025 13:36:16 +0200 Subject: [PATCH 018/173] Add failing test case --- .../Formats/LegacyBeatmapEncoderTest.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index caebf52026..e27146a86f 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -211,6 +211,31 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedAfterEncode.skin.Configuration.CustomComboColours, Has.Count.EqualTo(8)); } + [Test] + public void TestEncodeStabilityOfSliderWithFractionalCoordinates() + { + Slider originalSlider = new Slider + { + Position = new Vector2(0.6f), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PERFECT_CURVE), + new PathControlPoint(new Vector2(25.6f, 78.4f)), + new PathControlPoint(new Vector2(55.8f, 34.2f)), + }) + }; + var beatmap = new Beatmap + { + HitObjects = { originalSlider } + }; + + var encoded = encodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty))); + var decodedAfterEncode = decodeFromLegacy(encoded, string.Empty, version: LegacyBeatmapEncoder.FIRST_LAZER_VERSION); + var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0]; + Assert.That(decodedSlider.Path.ControlPoints.Select(p => p.Position), + Is.EquivalentTo(originalSlider.Path.ControlPoints.Select(p => p.Position))); + } + private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b) { // equal to null, no need to SequenceEqual @@ -233,11 +258,11 @@ namespace osu.Game.Tests.Beatmaps.Formats } } - private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name) + private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name, int version = LegacyDecoder.LATEST_VERSION) { using (var reader = new LineBufferedReader(stream)) { - var beatmap = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(reader); + var beatmap = new LegacyBeatmapDecoder(version) { ApplyOffsets = false }.Decode(reader); var beatmapSkin = new TestLegacySkin(beatmaps_resource_store, name); stream.Seek(0, SeekOrigin.Begin); beatmapSkin.Configuration = new LegacySkinDecoder().Decode(reader); From eab02a2aa54bedb6305d0c4b4d90ded243fe29e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 10 Jun 2025 14:04:56 +0200 Subject: [PATCH 019/173] Fix lack of slider encode-decode stability due to truncating control point coordinates Mostly closes https://github.com/ppy/osu/issues/33505. Compare https://github.com/ppy/osu/blob/97e6187f0d7c3dbee896596a623e34627135bf0e/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs#L56-L59 I say "mostly" here because I'm rather skeptical that this is 100% rock solid still, for one reason - namely that the game stores path control point coordinates relative to the head, then turns them into absolute coordinates when encoding, and then on decoding turns them back into coordinates relative to the head, which in floating-point world is a Bad Idea because of round-off error. But I'm not fixing that without introducing a completely new beatmap format or rewriting half the editor to address that, so I'll just pretend that I don't know any of this until someone notices. --- osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 0162c8017b..c5a6c9e83d 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -335,11 +335,14 @@ namespace osu.Game.Rulesets.Objects.Legacy ArrayPool<(PathType, int)>.Shared.Return(segmentsBuffer); } - static Vector2 readPoint(string value, Vector2 startPos) + Vector2 readPoint(string value, Vector2 startPos) { string[] vertexSplit = value.Split(':'); - Vector2 pos = new Vector2((int)Parsing.ParseDouble(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) - startPos; + Vector2 pos = formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION + ? new Vector2(Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) + : new Vector2((int)Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)); + pos -= startPos; return pos; } } From cad389722ebef46ce10ad288ca7327ece4b894c2 Mon Sep 17 00:00:00 2001 From: marvin Date: Tue, 10 Jun 2025 21:22:57 +0200 Subject: [PATCH 020/173] Maintain scroll position relative to hovered drawable in ExpandingToolboxContainer --- .../Graphics/Containers/ExpandingContainer.cs | 25 +++---- .../Edit/ExpandingToolboxContainer.cs | 70 +++++++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 477de616ac..93595f186f 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -40,21 +40,22 @@ namespace osu.Game.Graphics.Containers RelativeSizeAxes = Axes.Y; Width = contractedWidth; - InternalChild = new OsuScrollContainer + InternalChild = CreateScrollContainer().With(s => { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = FillFlow = new FillFlowContainer - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - }, - }; + s.RelativeSizeAxes = Axes.Both; + s.ScrollbarVisible = false; + }).WithChild(FillFlow = new FillFlowContainer + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }); } + protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + private ScheduledDelegate? hoverExpandEvent; protected override void LoadComplete() diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index 2a94ae6017..4119943f76 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -1,10 +1,13 @@ // 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.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Screens.Edit; @@ -21,6 +24,7 @@ namespace osu.Game.Rulesets.Edit private readonly Bindable contractSidebars = new Bindable(); private bool expandOnHover; + private OffsetMaintainingScrollContainer scrollContainer = null!; [Resolved] private Editor? editor { get; set; } @@ -42,6 +46,25 @@ namespace osu.Game.Rulesets.Edit config.BindWith(OsuSetting.EditorContractSidebars, contractSidebars); } + protected override OsuScrollContainer CreateScrollContainer() => scrollContainer = new OffsetMaintainingScrollContainer(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + var inputManager = GetContainingInputManager(); + + if (inputManager != null) + { + Expanded.BindValueChanged(_ => + { + var position = new Vector2(ScreenSpaceDrawQuad.Centre.X, inputManager.CurrentState.Mouse.Position.Y); + + scrollContainer.TargetDrawable = Children.FirstOrDefault(it => it.Contains(position)); + }); + } + } + protected override void Update() { base.Update(); @@ -53,10 +76,57 @@ namespace osu.Game.Rulesets.Edit expandOnHover = requireContracting; Expanded.Value = !expandOnHover; } + + if (scrollContainer.TargetDrawable != null && !TransformsForTargetMember(nameof(Width)).Any()) + scrollContainer.TargetDrawable = null; } protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) => true; + + private partial class OffsetMaintainingScrollContainer : OsuScrollContainer + { + private Drawable? targetDrawable; + private float targetPosition; + + public Drawable? TargetDrawable + { + get => targetDrawable; + set + { + targetDrawable = value; + + if (value != null) + targetPosition = ToLocalSpace(value.ScreenSpaceDrawQuad.TopLeft).Y; + } + } + + protected override void UpdateAfterChildren() + { + if (targetDrawable != null) + { + float currentPosition = ToLocalSpace(targetDrawable.ScreenSpaceDrawQuad.TopLeft).Y; + + if (!Precision.AlmostEquals(targetPosition, currentPosition)) + { + double offset = currentPosition - targetPosition; + + double scrollTarget = Math.Clamp(Current + offset, 0, ScrollableExtent); + + ScrollTo(scrollTarget, false, double.PositiveInfinity); + } + } + + base.UpdateAfterChildren(); + } + + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null) + { + targetDrawable = null; + + base.OnUserScroll(value, animated, distanceDecay); + } + } } } From 41d4d55f22c1fca34596dce0ec9089b34b71eb78 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 11 Jun 2025 16:37:28 +0900 Subject: [PATCH 021/173] Add tests --- .../NonVisual/TestSceneUpdateManager.cs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs new file mode 100644 index 0000000000..d118678fde --- /dev/null +++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs @@ -0,0 +1,179 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual; +using osu.Game.Updater; + +namespace osu.Game.Tests.NonVisual +{ + [HeadlessTest] + public class TestSceneUpdateManager : OsuTestScene + { + [Cached(typeof(INotificationOverlay))] + private readonly INotificationOverlay notifications = new TestNotificationOverlay(); + + private TestUpdateManager manager = null!; + private OsuConfigManager config = null!; + + [SetUpSteps] + public void SetupSteps() + { + AddStep("add manager", () => + { + config = new OsuConfigManager(LocalStorage); + config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer); + + Child = new DependencyProvidingContainer + { + CachedDependencies = [(typeof(OsuConfigManager), config)], + Child = manager = new TestUpdateManager() + }; + }); + + // Updates should be checked when the object is loaded for the first time. + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("1 check completed", () => manager.Completions, () => Is.EqualTo(1)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + /// + /// Updates should be checked when the release stream is changed. + /// + [Test] + public void TestReleaseStreamChanged() + { + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + /// + /// Updates should be checked once more if the release stream is changed during an going check + /// + [Test] + public void TestReleaseStreamChangedDuringCheck() + { + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); + AddUntilStep("check pending", () => manager.IsPending); + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer)); + + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete one check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + /// + /// Updates should be checked when the user requests them to. + /// + [Test] + public void TestUserRequest() + { + AddStep("request check", () => manager.CheckForUpdateAsync()); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + [Test] + public void TestUserRequestReturnsExistingCheck() + { + Task task1 = null!; + Task task2 = null!; + + // This part covering double user input is not really possible because the settings button is disabled during the check, + // but it's kept here for sanity in-case the update manager is used as a standalone object elsewhere. + + AddStep("request check", () => task1 = manager.CheckForUpdateAsync()); + AddUntilStep("check pending", () => manager.IsPending); + AddStep("request check", () => task2 = manager.CheckForUpdateAsync()); + AddAssert("second request returned original task", () => task2 == task1); + + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + + // This next part tests for the user requesting an update during a background check, and is possible to occur in practice. + + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); + AddUntilStep("check pending", () => manager.IsPending); + AddStep("request check", () => + { + task1 = manager.CurrentTask; + task2 = manager.CheckForUpdateAsync(); + }); + AddAssert("second request returned original task", () => task2 == task1); + + AddStep("complete check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + private class TestUpdateManager : UpdateManager + { + public bool IsPending { get; private set; } + public int Completions { get; private set; } + + public Task CurrentTask { get; private set; } = Task.CompletedTask; + + private TaskCompletionSource? pendingCheck; + + protected override Task PerformUpdateCheck() + { + var task = Task.Run(async () => + { + var check = pendingCheck = new TaskCompletionSource(); + IsPending = true; + + bool result = await check.Task.ConfigureAwait(false); + IsPending = false; + Completions++; + + return result; + }); + + CurrentTask = task; + return task; + } + + public void Complete() + { + pendingCheck?.SetResult(true); + } + } + + private class TestNotificationOverlay : INotificationOverlay + { + public void Post(Notification notification) + { + } + + public void Hide() + { + } + + public IBindable UnreadCount { get; } = new Bindable(); + + public IEnumerable AllNotifications { get; } = Enumerable.Empty(); + } + } +} From e2d8c393886d00324f99c4c68472589b6ab71b05 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 11 Jun 2025 15:10:50 +0900 Subject: [PATCH 022/173] Adjust `UpdateManager` to keep a check stream There are two primary paths here: 1. A user requests an update. The existing request is used if it's in progress, or a new request is made and processed immediately. 2. Something is changed (e.g. the release stream) that triggers a background request. A new request is made to run after the existing one. --- osu.Game/Updater/UpdateManager.cs | 52 +++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 354a8da46b..a297313de5 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -43,7 +43,7 @@ namespace osu.Game.Updater protected IBindable ReleaseStream => releaseStream; private readonly Bindable releaseStream = new Bindable(); - private readonly object updateTaskLock = new object(); + private bool updateCheckRequested; private Task? updateCheckTask; protected override void LoadComplete() @@ -73,24 +73,50 @@ namespace osu.Game.Updater scheduleUpdateCheck(); } - private void scheduleUpdateCheck() => Schedule(() => Task.Run(CheckForUpdateAsync)); - - public async Task CheckForUpdateAsync() + protected override void Update() { - if (!CanCheckForUpdate) - return false; + base.Update(); + processScheduledUpdateCheck(); + } - Task waitTask; + /// + /// Schedules a request to check for new updates to begin as soon as any existing check completes. + /// + private void scheduleUpdateCheck() + { + updateCheckRequested = true; + } - lock (updateTaskLock) - waitTask = (updateCheckTask ??= PerformUpdateCheck()); + /// + /// Processes an ongoing request to check for new updates. + /// + private void processScheduledUpdateCheck() + { + if (!updateCheckRequested) + return; - bool hasUpdates = await waitTask.ConfigureAwait(false); + if (updateCheckTask?.IsCompleted == false) + return; - lock (updateTaskLock) - updateCheckTask = null; + if (CanCheckForUpdate) + updateCheckTask = PerformUpdateCheck(); - return hasUpdates; + updateCheckRequested = false; + } + + /// + /// Immediately checks for any available updates, or returns the existing update task. + /// + /// true if any updates are available, false otherwise. + public Task CheckForUpdateAsync() + { + if (updateCheckTask?.IsCompleted == false) + return updateCheckTask; + + scheduleUpdateCheck(); + processScheduledUpdateCheck(); + + return updateCheckTask ?? Task.FromResult(false); } /// From 981cf62c23cb0bf5d20d290cde42444310f52f10 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 11 Jun 2025 16:43:32 +0900 Subject: [PATCH 023/173] Partial classes --- osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs index d118678fde..ce5a1fd154 100644 --- a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs +++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs @@ -17,7 +17,7 @@ using osu.Game.Updater; namespace osu.Game.Tests.NonVisual { [HeadlessTest] - public class TestSceneUpdateManager : OsuTestScene + public partial class TestSceneUpdateManager : OsuTestScene { [Cached(typeof(INotificationOverlay))] private readonly INotificationOverlay notifications = new TestNotificationOverlay(); @@ -128,7 +128,7 @@ namespace osu.Game.Tests.NonVisual AddUntilStep("no check pending", () => !manager.IsPending); } - private class TestUpdateManager : UpdateManager + private partial class TestUpdateManager : UpdateManager { public bool IsPending { get; private set; } public int Completions { get; private set; } @@ -161,7 +161,7 @@ namespace osu.Game.Tests.NonVisual } } - private class TestNotificationOverlay : INotificationOverlay + private partial class TestNotificationOverlay : INotificationOverlay { public void Post(Notification notification) { From 359e3ac8a51aa57bf8c88591c885f0408a752336 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 11 Jun 2025 16:46:37 +0900 Subject: [PATCH 024/173] Expand tests to perform a second check To ensure it doesn't get stuck in a state where new checks aren't performed. --- .../NonVisual/TestSceneUpdateManager.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs index ce5a1fd154..fd2a3acb2f 100644 --- a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs +++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs @@ -59,10 +59,17 @@ namespace osu.Game.Tests.NonVisual AddStep("complete check", () => manager.Complete()); AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); AddUntilStep("no check pending", () => !manager.IsPending); + + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer)); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); } /// - /// Updates should be checked once more if the release stream is changed during an going check + /// Updates should be checked once more if the release stream is changed during an going check. /// [Test] public void TestReleaseStreamChangedDuringCheck() @@ -92,8 +99,18 @@ namespace osu.Game.Tests.NonVisual AddStep("complete check", () => manager.Complete()); AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); AddUntilStep("no check pending", () => !manager.IsPending); + + AddStep("request check", () => manager.CheckForUpdateAsync()); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); } + /// + /// Any ongoing request should be returned when the user requests a new one. + /// [Test] public void TestUserRequestReturnsExistingCheck() { From 18979d0ed4bab65d94511d1deb14c2d5ae21f303 Mon Sep 17 00:00:00 2001 From: marvin Date: Wed, 11 Jun 2025 09:47:39 +0200 Subject: [PATCH 025/173] Add comment explaining mouse position handling --- osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index 4119943f76..d6f8112514 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -58,6 +58,8 @@ namespace osu.Game.Rulesets.Edit { Expanded.BindValueChanged(_ => { + // When state changes from expanded -> collapsed the mouse is no longer within the toolbox so there would be no + // hovered children if we used the mouse position directly var position = new Vector2(ScreenSpaceDrawQuad.Centre.X, inputManager.CurrentState.Mouse.Position.Y); scrollContainer.TargetDrawable = Children.FirstOrDefault(it => it.Contains(position)); From e495843267c8354ea30b2a1a229ee6a445185dc5 Mon Sep 17 00:00:00 2001 From: marvin Date: Wed, 11 Jun 2025 09:48:51 +0200 Subject: [PATCH 026/173] Remove per-frame check for transform --- osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index d6f8112514..55449ff4d9 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -78,9 +78,6 @@ namespace osu.Game.Rulesets.Edit expandOnHover = requireContracting; Expanded.Value = !expandOnHover; } - - if (scrollContainer.TargetDrawable != null && !TransformsForTargetMember(nameof(Width)).Any()) - scrollContainer.TargetDrawable = null; } protected override bool OnMouseDown(MouseDownEvent e) => true; From 9f133ac997422c8a1f11cef05578fcaa59f67483 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 11 Jun 2025 16:51:03 +0900 Subject: [PATCH 027/173] Make check task non-nullable Keeping unnecessary nullables around is a pet-peeve of mine. This simplifies things. --- osu.Game/Updater/UpdateManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index a297313de5..279517db6c 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -44,7 +44,7 @@ namespace osu.Game.Updater private readonly Bindable releaseStream = new Bindable(); private bool updateCheckRequested; - private Task? updateCheckTask; + private Task updateCheckTask = Task.FromResult(false); protected override void LoadComplete() { @@ -95,7 +95,7 @@ namespace osu.Game.Updater if (!updateCheckRequested) return; - if (updateCheckTask?.IsCompleted == false) + if (!updateCheckTask.IsCompleted) return; if (CanCheckForUpdate) @@ -110,13 +110,13 @@ namespace osu.Game.Updater /// true if any updates are available, false otherwise. public Task CheckForUpdateAsync() { - if (updateCheckTask?.IsCompleted == false) + if (!updateCheckTask.IsCompleted) return updateCheckTask; scheduleUpdateCheck(); processScheduledUpdateCheck(); - return updateCheckTask ?? Task.FromResult(false); + return updateCheckTask; } /// From 0cbc5e3593b37fa807cbfa24997c647707bc6286 Mon Sep 17 00:00:00 2001 From: marvin Date: Wed, 11 Jun 2025 10:15:54 +0200 Subject: [PATCH 028/173] Move `InternalChild` assignment into BDL --- .../Graphics/Containers/ExpandingContainer.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 93595f186f..ad5c65c10e 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -40,18 +41,24 @@ namespace osu.Game.Graphics.Containers RelativeSizeAxes = Axes.Y; Width = contractedWidth; - InternalChild = CreateScrollContainer().With(s => - { - s.RelativeSizeAxes = Axes.Both; - s.ScrollbarVisible = false; - }).WithChild(FillFlow = new FillFlowContainer + FillFlow = new FillFlowContainer { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - }); + }; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = CreateScrollContainer().With(s => + { + s.RelativeSizeAxes = Axes.Both; + s.ScrollbarVisible = false; + }).WithChild(FillFlow); } protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); From a38e25115bb03dfdd7311354254bf8dfede9b178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 12 Jun 2025 10:33:42 +0200 Subject: [PATCH 029/173] Pick better initial beatmap status when submitting Addresses https://github.com/ppy/osu/discussions/33291 This is a half-baked RFC because things are awkward. For this to work correctly the submission flow has to do an API request, because one, the local beatmap status has been overwritten with "locally modified", and secondly, even if it *was* there, there's no guarantee that it was actually *up to date*. And if we have to do an API request then there are two choices: - Hard block on the API request and don't show anything until it completes which possibly means waiting at a spinner for several seconds if someone's on bad internet. - Don't block on the API request --- but then there's no guarantee what timing the API request completes at, which means that possibly the user could change the dropdown before the API request completes, and the API request will overwrite their choice, so to prevent that block the dropdown until the request completes. This is what this commit does. --- .../Submission/BeatmapSubmissionScreen.cs | 7 +++++ .../Submission/BeatmapSubmissionSettings.cs | 2 ++ .../Submission/ScreenSubmissionSettings.cs | 27 ++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs index 3a9eb2c1b0..03ab23d8e4 100644 --- a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs @@ -191,6 +191,13 @@ namespace osu.Game.Screens.Edit.Submission }); completedSample = audio.Samples.Get(@"UI/bss-complete"); + + if (Beatmap.Value.BeatmapSetInfo.OnlineID > 0) + { + var req = new GetBeatmapSetRequest(Beatmap.Value.BeatmapSetInfo.OnlineID); + api.Queue(req); + settings.LatestOnlineStateRequest = req; + } } private void createBeatmapSet() diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs index 8cccc339a6..a1f3861d29 100644 --- a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs @@ -8,6 +8,8 @@ namespace osu.Game.Screens.Edit.Submission { public class BeatmapSubmissionSettings { + public GetBeatmapSetRequest? LatestOnlineStateRequest { get; set; } + public Bindable Target { get; } = new Bindable(); public Bindable NotifyOnDiscussionReplies { get; } = new Bindable(); diff --git a/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs index 969105b5c6..7b80fdee7d 100644 --- a/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs +++ b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays; using osuTK; @@ -25,8 +28,11 @@ namespace osu.Game.Screens.Edit.Submission public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission; + [Resolved] + private BeatmapSubmissionSettings settings { get; set; } = null!; + [BackgroundDependencyLoader] - private void load(OsuConfigManager configManager, OsuColour colours, BeatmapSubmissionSettings settings) + private void load(OsuConfigManager configManager, OsuColour colours) { configManager.BindWith(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, settings.NotifyOnDiscussionReplies); configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission); @@ -63,6 +69,25 @@ namespace osu.Game.Screens.Edit.Submission }, } }); + + switch (settings.LatestOnlineStateRequest?.CompletionState) + { + case APIRequestCompletionState.Completed: + setSubmissionTargetFromLatestOnlineState(); + break; + + case APIRequestCompletionState.Waiting: + settings.Target.Disabled = true; + settings.LatestOnlineStateRequest.Success += _ => setSubmissionTargetFromLatestOnlineState(); + break; + } + } + + private void setSubmissionTargetFromLatestOnlineState() + { + Debug.Assert(settings.LatestOnlineStateRequest != null); + settings.Target.Disabled = false; + settings.Target.Value = settings.LatestOnlineStateRequest.Response?.Status >= BeatmapOnlineStatus.Pending ? BeatmapSubmissionTarget.Pending : BeatmapSubmissionTarget.WIP; } } } From 3816c5d95f3ec88e0ab4a0ebf433d0740de86931 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 11 Jun 2025 19:15:51 +0900 Subject: [PATCH 030/173] Add support for traversing between groups using shift-left/right Closes https://github.com/ppy/osu/issues/33599. --- .../TestSceneBeatmapCarouselArtistGrouping.cs | 10 +++- ...tSceneBeatmapCarouselDifficultyGrouping.cs | 58 +++++++++++++++++-- osu.Game/Graphics/Carousel/Carousel.cs | 42 ++++++++++++-- .../Input/Bindings/GlobalActionContainer.cs | 9 +++ .../GlobalActionKeyBindingStrings.cs | 10 ++++ osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 2 + 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs index af3bda8928..521221f0c7 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs @@ -114,17 +114,25 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SelectPrevPanel(); SelectPrevPanel(); + ICarouselPanel? groupPanel = null; + + AddStep("get group panel", () => groupPanel = GetKeyboardSelectedPanel()); + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, () => Is.EqualTo(groupPanel)); SelectPrevSet(); WaitForBeatmapSelection(0, 1); AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, () => Is.EqualTo(groupPanel)); SelectPrevSet(); WaitForBeatmapSelection(0, 1); - AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + // Expanding a group will move keyboard selection to the selected beatmap if contained. + AddAssert("keyboard selected panel is expanded", () => groupPanel?.Expanded.Value, () => Is.True); + AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs index 52c89d7c4e..2c127f80a9 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs @@ -102,19 +102,70 @@ namespace osu.Game.Tests.Visual.SongSelectV2 WaitForBeatmapSelection(0, 0); SelectPrevPanel(); + + ICarouselPanel? groupPanel = null; + + AddStep("get group panel", () => groupPanel = GetKeyboardSelectedPanel()); + AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, () => Is.EqualTo(groupPanel)); SelectPrevSet(); WaitForBeatmapSelection(0, 0); AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, () => Is.EqualTo(groupPanel)); SelectPrevSet(); WaitForBeatmapSelection(0, 0); - AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); + // Expanding a group will move keyboard selection to the selected beatmap if contained. + AddAssert("keyboard selected panel is expanded", () => groupPanel?.Expanded.Value, () => Is.True); + AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf); } + [Test] + public void TestKeyboardGroupTraversal() + { + SelectNextSet(); + WaitForBeatmapSelection(0, 0); + checkBeatmapIsKeyboardSelected(); + + SelectNextGroup(); + WaitForBeatmapSelection(0, 0); + WaitForExpandedGroup(1); + checkGroupKeyboardSelected(1); + + SelectNextGroup(); + WaitForBeatmapSelection(0, 0); + WaitForExpandedGroup(2); + checkGroupKeyboardSelected(2); + + SelectNextGroup(); + WaitForBeatmapSelection(0, 0); + WaitForExpandedGroup(0); + checkBeatmapIsKeyboardSelected(); + + SelectPrevGroup(); + WaitForBeatmapSelection(0, 0); + WaitForExpandedGroup(2); + checkGroupKeyboardSelected(2); + } + + private void checkBeatmapIsKeyboardSelected() => + AddUntilStep("check keyboard selected group is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, () => Is.EqualTo(Carousel.CurrentSelection)); + + private void checkGroupKeyboardSelected(int index) => AddUntilStep($"check keyboard selected group is {index}", () => GetKeyboardSelectedPanel()?.Item?.Model, () => + { + var groupingFilter = Carousel.Filters.OfType().Single(); + + GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(index); + // offset by one because the group itself is included in the items list. + CarouselItem item = groupingFilter.GroupItems[g].ElementAt(0); + + return Is.EqualTo(item.Model); + }); + [Test] public void TestGroupSelectionOnHeaderMouse() { @@ -129,9 +180,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False); ClickVisiblePanel(0); - AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, Is.TypeOf); - AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True); - + // Expanding a group will move keyboard selection to the selected beatmap if contained. + AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, Is.TypeOf); AddAssert("selected panel is still beatmap", GetSelectedPanel, Is.TypeOf); } diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index ab3e860f8b..deadb4f288 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -233,6 +233,13 @@ namespace osu.Game.Graphics.Carousel protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) => Scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item); + /// + /// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target. + /// + /// The candidate item. + /// Whether the provided item is a valid group target. If false, more panels will be checked in the user's requested direction until a valid target is found. + protected virtual bool CheckValidForGroupSelection(CarouselItem item) => false; + /// /// When a user is traversing the carousel via set selection keys, assert whether the item provided is a valid target. /// @@ -467,6 +474,14 @@ namespace osu.Game.Graphics.Carousel case GlobalAction.ActivatePreviousSet: Scheduler.AddOnce(traverseSetSelection, -1); return true; + + case GlobalAction.ExpandPreviousGroup: + Scheduler.AddOnce(traverseGroupSelection, -1); + return true; + + case GlobalAction.ExpandNextGroup: + Scheduler.AddOnce(traverseGroupSelection, 1); + return true; } return false; @@ -520,23 +535,38 @@ namespace osu.Game.Graphics.Carousel } /// - /// Select the next valid selection relative to a current selection. + /// Select the next valid group selection relative to a current selection. + /// This is generally for keyboard based traversal. + /// + /// Positive for downwards, negative for upwards. + /// Whether selection was possible. + private void traverseGroupSelection(int direction) => traverseSelection(direction, CheckValidForGroupSelection); + + /// + /// Select the next valid set selection relative to a current selection. /// This is generally for keyboard based traversal. /// /// Positive for downwards, negative for upwards. /// Whether selection was possible. private void traverseSetSelection(int direction) { - if (carouselItems == null || carouselItems.Count == 0) return; - // If the user has a different keyboard selection and requests // set selection, first transfer the keyboard selection to actual selection. + // + // It is assumed that selecting a set will immediately change selection to one of its children. if (currentKeyboardSelection.CarouselItem != null && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) { Activate(currentKeyboardSelection.CarouselItem); return; } + traverseSelection(direction, CheckValidForSetSelection); + } + + private void traverseSelection(int direction, Func predicate) + { + if (carouselItems == null || carouselItems.Count == 0) return; + int originalIndex; int newIndex; @@ -553,7 +583,7 @@ namespace osu.Game.Graphics.Carousel // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early. if (direction < 0) { - while (newIndex > 0 && !CheckValidForSetSelection(carouselItems[newIndex])) + while (newIndex > 0 && !predicate(carouselItems[newIndex])) newIndex--; } } @@ -569,9 +599,9 @@ namespace osu.Game.Graphics.Carousel var newItem = carouselItems[newIndex]; - if (CheckValidForSetSelection(newItem)) + if (predicate(newItem)) { - HandleItemActivated(newItem); + Activate(newItem); return; } } while (true); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 83c2af5d73..db7d1158e4 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -199,6 +199,9 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.Left, GlobalAction.ActivatePreviousSet), new KeyBinding(InputKey.Right, GlobalAction.ActivateNextSet), + new KeyBinding(new[] { InputKey.Shift, InputKey.Left }, GlobalAction.ExpandPreviousGroup), + new KeyBinding(new[] { InputKey.Shift, InputKey.Right }, GlobalAction.ExpandNextGroup), + new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection), new KeyBinding(InputKey.F2, GlobalAction.SelectNextRandom), new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom), @@ -506,6 +509,12 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDiscardUnsavedChanges))] EditorDiscardUnsavedChanges, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExpandPreviousGroup))] + ExpandPreviousGroup, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExpandNextGroup))] + ExpandNextGroup, } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 4401efaced..994f924d6b 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -139,6 +139,16 @@ namespace osu.Game.Localisation /// public static LocalisableString ActivateNextSet => new TranslatableString(getKey(@"activate_next_set"), @"Activate next set"); + /// + /// "Expand previous group" + /// + public static LocalisableString ExpandPreviousGroup => new TranslatableString(getKey(@"expand_previous_group"), @"Expand previous group"); + + /// + /// "Expand next group" + /// + public static LocalisableString ExpandNextGroup => new TranslatableString(getKey(@"expand_next_group"), @"Expand next group"); + /// /// "Home" /// diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 4d066e0323..5031e02443 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -339,6 +339,8 @@ namespace osu.Game.Screens.SelectV2 RequestRecommendedSelection(beatmaps); } + protected override bool CheckValidForGroupSelection(CarouselItem item) => item.Model is GroupDefinition; + protected override bool CheckValidForSetSelection(CarouselItem item) { switch (item.Model) From f82cb6d76798fb0491ddca0ef9ff194919c652d4 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 11 Jun 2025 19:49:33 +0900 Subject: [PATCH 031/173] Fix textbox shift-left/right conflicting with new group changing bindings --- osu.Game/Screens/SelectV2/FilterControl.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 4700842a96..c0ccf0ab93 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -279,6 +279,10 @@ namespace osu.Game.Screens.SelectV2 public override bool OnPressed(KeyBindingPressEvent e) { + // Conflicts with default group navigation keys (shift-left shift-right). + if (e.Action == PlatformAction.SelectBackwardChar || e.Action == PlatformAction.SelectForwardChar) + return false; + // the "cut" platform key binding (shift-delete) conflicts with the beatmap deletion action. if (e.Action == PlatformAction.Cut && e.ShiftPressed && e.CurrentState.Keyboard.Keys.IsPressed(Key.Delete)) return false; From 52ba22ca1f0f298332b46fc731947212799111c6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 11 Jun 2025 19:57:20 +0900 Subject: [PATCH 032/173] Add support for toggling current group expansion using shift-enter Closes https://github.com/ppy/osu/issues/33617. --- .../SongSelectV2/BeatmapCarouselTestScene.cs | 7 +++++ ...tSceneBeatmapCarouselDifficultyGrouping.cs | 31 +++++++++++++++++++ osu.Game/Graphics/Carousel/Carousel.cs | 29 +++++++++++++---- .../Input/Bindings/GlobalActionContainer.cs | 5 +++ .../GlobalActionKeyBindingStrings.cs | 5 +++ osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 5 +++ 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index 3943b13286..fc5c09ecef 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -184,6 +184,13 @@ namespace osu.Game.Tests.Visual.SongSelectV2 protected void WaitForFiltering() => AddUntilStep("filtering finished", () => Carousel.IsFiltering, () => Is.False); protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target)); + protected void ToggleGroupCollapse() => AddStep("toggle group collapse", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Enter); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + protected void SelectNextGroup() => AddStep("select next group", () => { InputManager.PressKey(Key.ShiftLeft); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs index 2c127f80a9..5a03e05344 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs @@ -124,6 +124,37 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf); } + [Test] + public void TestKeyboardGroupToggleCollapse_SelectionContained() + { + SelectNextSet(); + WaitForBeatmapSelection(0, 0); + checkBeatmapIsKeyboardSelected(); + + ToggleGroupCollapse(); + checkGroupKeyboardSelected(0); + + ToggleGroupCollapse(); + checkBeatmapIsKeyboardSelected(); + } + + [Test] + public void TestKeyboardGroupToggleCollapse_SelectionNotContained() + { + SelectNextSet(); + WaitForBeatmapSelection(0, 0); + checkBeatmapIsKeyboardSelected(); + + SelectNextGroup(); + checkGroupKeyboardSelected(1); + + ToggleGroupCollapse(); + checkGroupKeyboardSelected(1); + + ToggleGroupCollapse(); + checkGroupKeyboardSelected(1); + } + [Test] public void TestKeyboardGroupTraversal() { diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index deadb4f288..231b2958c6 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -482,6 +482,20 @@ namespace osu.Game.Graphics.Carousel case GlobalAction.ExpandNextGroup: Scheduler.AddOnce(traverseGroupSelection, 1); return true; + + case GlobalAction.ToggleCurrentGroup: + if (currentKeyboardSelection.CarouselItem != null && CheckValidForGroupSelection(currentKeyboardSelection.CarouselItem)) + { + // If keyboard selection is a group, toggle group and then change keyboard selection to actual selection. + Activate(currentKeyboardSelection.CarouselItem); + } + else + { + // If current keyboard selection is not a group, toggle closest group and move keyboard selection to that group. + traverseSelection(-1, CheckValidForGroupSelection, false); + } + + return true; } return false; @@ -563,7 +577,7 @@ namespace osu.Game.Graphics.Carousel traverseSelection(direction, CheckValidForSetSelection); } - private void traverseSelection(int direction, Func predicate) + private void traverseSelection(int direction, Func predicate, bool skipFirst = true) { if (carouselItems == null || carouselItems.Count == 0) return; @@ -579,12 +593,15 @@ namespace osu.Game.Graphics.Carousel { newIndex = originalIndex = currentKeyboardSelection.Index.Value; - // As a second special case, if we're set selecting backwards and the current selection isn't a set, - // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early. - if (direction < 0) + if (skipFirst) { - while (newIndex > 0 && !predicate(carouselItems[newIndex])) - newIndex--; + // As a second special case, if we're set selecting backwards and the current selection isn't a set, + // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early. + if (direction < 0) + { + while (newIndex > 0 && !predicate(carouselItems[newIndex])) + newIndex--; + } } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index db7d1158e4..2aeb73d6c5 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -202,6 +202,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Shift, InputKey.Left }, GlobalAction.ExpandPreviousGroup), new KeyBinding(new[] { InputKey.Shift, InputKey.Right }, GlobalAction.ExpandNextGroup), + new KeyBinding(new[] { InputKey.Shift, InputKey.Enter }, GlobalAction.ToggleCurrentGroup), + new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection), new KeyBinding(InputKey.F2, GlobalAction.SelectNextRandom), new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom), @@ -515,6 +517,9 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExpandNextGroup))] ExpandNextGroup, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleCurrentGroup))] + ToggleCurrentGroup, } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 994f924d6b..9c484a5cb0 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -149,6 +149,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ExpandNextGroup => new TranslatableString(getKey(@"expand_next_group"), @"Expand next group"); + /// + /// "Toggle expansion of current group" + /// + public static LocalisableString ToggleCurrentGroup => new TranslatableString(getKey(@"toggle_current_group"), @"Toggle expansion of current group"); + /// /// "Home" /// diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 5031e02443..9f749ca2d2 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -230,6 +230,11 @@ namespace osu.Game.Screens.SelectV2 } setExpandedGroup(group); + + // If the active selection is within this group, it should get keyboard focus immediately. + if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is BeatmapInfo info) + RequestSelection(info); + return; case BeatmapSetInfo setInfo: From e59f9b1aa7bada9ada95c7d89cdcf81359a3a005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 12 Jun 2025 13:37:41 +0200 Subject: [PATCH 033/173] Add failing test scene --- .../Visual/Gameplay/TestSceneReplayPlayer.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index 81dd23661c..5db7a78983 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -6,11 +6,15 @@ using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Replays; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -157,6 +161,37 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("Jumped forwards", () => Player.GameplayClockContainer.CurrentTime - lastTime > 500); } + [Test] + public void TestReplayDoesNotFailUntilRunningOutOfFrames() + { + var score = new Score + { + ScoreInfo = TestResources.CreateTestScoreInfo(Beatmap.Value.BeatmapInfo), + Replay = new Replay + { + Frames = + { + new OsuReplayFrame(0, Vector2.Zero), + new OsuReplayFrame(10000, Vector2.Zero), + } + } + }; + score.ScoreInfo.Mods = []; + score.ScoreInfo.Rank = ScoreRank.F; + AddStep("set global state", () => + { + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + Ruleset.Value = Beatmap.Value.BeatmapInfo.Ruleset; + SelectedMods.Value = score.ScoreInfo.Mods; + }); + AddStep("create player", () => Player = new TestReplayPlayer(score, showResults: false)); + AddStep("load player", () => LoadScreen(Player)); + AddUntilStep("wait for loaded", () => Player.IsCurrentScreen()); + AddStep("seek to 8000", () => Player.Seek(8000)); + AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); + AddAssert("player failed after 10000", () => Player.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(10000)); + } + private void loadPlayerWithBeatmap(IBeatmap? beatmap = null) { AddStep("create player", () => From 4fd2a488b7a8b4158e7a610cc3cf92efbe4a6ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 13 Jun 2025 08:39:49 +0200 Subject: [PATCH 034/173] Floor star rating to 2 decimal places rather than rounding Rounding semi-regularly confuses users who aim for star rating pass / FC medals and then feel they have been cheated out of a medal because they passed an "X-star beatmap", but the actual star rating of the beatmap is slightly under X. The latest instance of this can be found at https://osu.ppy.sh/community/forums/topics/2091333?n=2. The relevant beatmap there is https://osu.ppy.sh/beatmapsets/2162554#osu/4746232, whose raw star rating is 6.9976070253117344. The other direction would be to fix the star rating medals instead, but I think this is more reasonable given we already do similar things to accuracy displays. --- osu.Game.Tests/NonVisual/FormatUtilsTest.cs | 13 +++++++++++++ osu.Game/Utils/FormatUtils.cs | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs index a12658bd8b..0fcf754cf6 100644 --- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs @@ -21,5 +21,18 @@ namespace osu.Game.Tests.NonVisual { Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString()); } + + [TestCase(3, "3.00")] + [TestCase(3.3, "3.30")] + [TestCase(3.55, "3.55")] + [TestCase(3.553, "3.55")] + [TestCase(3.557, "3.55")] + [TestCase(3.9999, "3.99")] + [TestCase(3.999999, "3.99")] + [TestCase(4, "4.00")] + public void TestStarRatingFormatting(double input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.FormatStarRating().ToString()); + } } } diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index f7250c6833..fa7d6595e9 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -36,7 +36,16 @@ namespace osu.Game.Utils /// Formats the supplied star rating in a consistent, simplified way. /// /// The star rating to be formatted. - public static LocalisableString FormatStarRating(this double starRating) => starRating.ToLocalisableString("0.00"); + public static LocalisableString FormatStarRating(this double starRating) + { + // for the sake of display purposes, we don't want to show a user a "rounded up" star rating to the next whole number. + // i.e. a beatmap which has a star rating of 6.9999* should never show as 7.00*. + // this matters for star rating medals which use hard cutoffs at whole numbers, + // which then confuses users when they beat a 6.9999* beatmap but don't get the 7-star medal. + starRating = Math.Floor(starRating * 100) / 100; + + return starRating.ToLocalisableString("0.00"); + } /// /// Finds the number of digits after the decimal. From eda489b911a1f8dbcf718f26eb21a396283f6870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 13 Jun 2025 08:58:01 +0200 Subject: [PATCH 035/173] Adjust implementation of star rating filtering in song select to new star rating flooring behaviour Required for things to not be broken after the previous change. --- .../Filtering/FilterQueryParserTest.cs | 34 ++++++++++++------- .../Select/Carousel/CarouselBeatmap.cs | 3 +- osu.Game/Screens/Select/FilterQueryParser.cs | 8 ++++- .../SelectV2/BeatmapCarouselFilterMatching.cs | 3 +- osu.Game/Utils/FormatUtils.cs | 14 ++++---- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index f4e324d7ba..37b7d71d2b 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -85,16 +85,6 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); } - /* - * The following tests have been written a bit strangely (they don't check exact - * bound equality with what the filter says). - * This is to account for floating-point arithmetic issues. - * For example, specifying a bpm<140 filter would previously match beatmaps with BPM - * of 139.99999, which would be displayed in the UI as 140. - * Due to this the tests check the last tick inside the range and the first tick - * outside of the range. - */ - [TestCase("star")] [TestCase("stars")] public void TestApplyStarQueries(string variant) @@ -105,11 +95,31 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); Assert.AreEqual(1, filterCriteria.SearchTerms.Length); Assert.IsNotNull(filterCriteria.StarDifficulty.Max); - Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d); - Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d); + Assert.AreEqual(filterCriteria.StarDifficulty.Max, 4.00d); Assert.IsNull(filterCriteria.StarDifficulty.Min); } + [Test] + public void TestStarQueriesInclusive() + { + const string query = $"stars>=6"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(filterCriteria.StarDifficulty.Min, 6.00d); + Assert.True(filterCriteria.StarDifficulty.IsLowerInclusive); + Assert.IsNull(filterCriteria.StarDifficulty.Max); + } + + /* + * The following tests have been written a bit strangely (they don't check exact + * bound equality with what the filter says). + * This is to account for floating-point arithmetic issues. + * For example, specifying a bpm<140 filter would previously match beatmaps with BPM + * of 139.99999, which would be displayed in the UI as 140. + * Due to this the tests check the last tick inside the range and the first tick + * outside of the range. + */ + [Test] public void TestApplyApproachRateQueries() { diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index dc77b0101e..02b5eb5b7a 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Screens.Select.Filter; +using osu.Game.Utils; namespace osu.Game.Screens.Select.Carousel { @@ -59,7 +60,7 @@ namespace osu.Game.Screens.Select.Carousel if (!match) return false; - match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating); + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating.FloorToDecimalDigits(2)); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(BeatmapInfo.Difficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(BeatmapInfo.Difficulty.DrainRate); match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(BeatmapInfo.Difficulty.CircleSize); diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 1094d88730..02a6da146e 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Select case "star": case "stars": case "sr": - return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0.01d / 2); + return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0); case "ar": return TryUpdateCriteriaRange(ref criteria.ApproachRate, op, value); @@ -309,6 +309,8 @@ namespace osu.Game.Screens.Select case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; + if (tolerance == 0) + range.IsLowerInclusive = range.IsUpperInclusive = true; break; case Operator.Greater: @@ -317,6 +319,8 @@ namespace osu.Game.Screens.Select case Operator.GreaterOrEqual: range.Min = value - tolerance; + if (tolerance == 0) + range.IsLowerInclusive = true; break; case Operator.Less: @@ -325,6 +329,8 @@ namespace osu.Game.Screens.Select case Operator.LessOrEqual: range.Max = value + tolerance; + if (tolerance == 0) + range.IsUpperInclusive = true; break; } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs index a776b2f796..f2f246093d 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; +using osu.Game.Utils; namespace osu.Game.Screens.SelectV2 { @@ -81,7 +82,7 @@ namespace osu.Game.Screens.SelectV2 if (!match) return false; - match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(beatmap.StarRating); + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(beatmap.StarRating.FloorToDecimalDigits(2)); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(beatmap.Difficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(beatmap.Difficulty.DrainRate); match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(beatmap.Difficulty.CircleSize); diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index fa7d6595e9..28776ea0bf 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -10,6 +10,12 @@ namespace osu.Game.Utils { public static class FormatUtils { + public static double FloorToDecimalDigits(this double value, uint digits) + { + double base10 = Math.Pow(10, digits); + return Math.Floor(value * base10) / base10; + } + /// /// Turns the provided accuracy into a percentage with 2 decimal places. /// @@ -21,9 +27,7 @@ namespace osu.Game.Utils // ie. a score which gets 89.99999% shouldn't ever show as 90%. // the reasoning for this is that cutoffs for grade increases are at whole numbers and displaying the required // percentile with a non-matching grade is confusing. - accuracy = Math.Floor(accuracy * 10000) / 10000; - - return accuracy.ToLocalisableString("0.00%"); + return accuracy.FloorToDecimalDigits(4).ToLocalisableString("0.00%"); } /// @@ -42,9 +46,7 @@ namespace osu.Game.Utils // i.e. a beatmap which has a star rating of 6.9999* should never show as 7.00*. // this matters for star rating medals which use hard cutoffs at whole numbers, // which then confuses users when they beat a 6.9999* beatmap but don't get the 7-star medal. - starRating = Math.Floor(starRating * 100) / 100; - - return starRating.ToLocalisableString("0.00"); + return starRating.FloorToDecimalDigits(2).ToLocalisableString("0.00"); } /// From 73a1f10dafc37968641ce4ee4413e82cfd0fb970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 12 Jun 2025 14:03:55 +0200 Subject: [PATCH 036/173] Ensure partial failed replays are played to their end Closes https://github.com/ppy/osu/issues/24285. This is not a perfect solution, as it is still possible for a replay to play *beyond* its end if the HP system doesn't fail it after it runs out of frames, but it's probably the best that can be done at this time. Notably this removes existing F rank checks because they were really not reliable. - Scores coming from stable will never present F rank, because rank is not stored to the replay, and the lowest rank that can be produced by `StandardisedScoreMigrationTools` is D. - lazer scores set prior to https://github.com/ppy/osu/pull/28058 will present F rank as long as the user has kept them in their local database and never exported and reimported them, for the same reason as above (rank not stored to replay). Also there have been many mechanics changes since, so it's not impossible for the replay to fail *before* the user actually did even in this case. - lazer scores set after https://github.com/ppy/osu/pull/28058 could technically rely on F rank but making them rely on it is annoying for several reasons: - The PR in question didn't bump `LegacyScoreEncoder.LATEST_VERSION`, so any checks based on the replay version field would be half-reliable anyway. - *Even after* the above, the replay version is only stored to realm as `TotalScoreVersion`, which *then gets bumped* on score version upgrades. So it can't even be used for any checks from that angle, you'd have to decode it from the score. - You *could* use `ClientVersion` because that's somewhat reliable, but that's stored *as string*, so you'd have to do some snipping to split off the `-lazer` suffix, then parse the version, then compare it. I started going through the motions of that before deciding that this is an edge case of an edge case and probably not worth spending time over the simple and obvious solution of just doing away with the rank check. Until I'm proven wrong, I guess. --- osu.Game/Screens/Play/ReplayPlayer.cs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 882e556965..c058238a0a 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -29,8 +29,6 @@ namespace osu.Game.Screens.Play private readonly Func, Score> createScore; - private readonly bool replayIsFailedScore; - private PlaybackSettings playbackSettings; [Cached(typeof(IGameplayLeaderboardProvider))] @@ -40,19 +38,28 @@ namespace osu.Game.Screens.Play private bool isAutoplayPlayback => GameplayState.Mods.OfType().Any(); - // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) + private double? lastFrameTime; + protected override bool CheckModsAllowFailure() { - if (!replayIsFailedScore && !isAutoplayPlayback) - return false; + // autoplay should be able to fail if the beatmap is not humanly beatable + if (isAutoplayPlayback) + return base.CheckModsAllowFailure(); - return base.CheckModsAllowFailure(); + // non-autoplay replays should be able to fail, but only after they've exhausted their frames. + // note that the rank isn't checked here - that's because it is generally unreliable. + // stable replays, as well as lazer replays recorded prior to https://github.com/ppy/osu/pull/28058, + // do not even *contain* the user's rank. + // not to mention possible gameplay mechanics changes that could make a replay fail sooner than it really should. + if (GameplayClockContainer.CurrentTime >= lastFrameTime) + return base.CheckModsAllowFailure(); + + return false; } public ReplayPlayer(Score score, PlayerConfiguration configuration = null) : this((_, _) => score, configuration) { - replayIsFailedScore = score.ScoreInfo.Rank == ScoreRank.F; } public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) @@ -95,6 +102,7 @@ namespace osu.Game.Screens.Play protected override void PrepareReplay() { DrawableRuleset?.SetReplayScore(Score); + lastFrameTime = Score.Replay.Frames.LastOrDefault()?.Time; } protected override Score CreateScore(IBeatmap beatmap) => createScore(beatmap, Mods.Value); From dc38c190df7e28225b731f9243de84f77f6f9f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 13 Jun 2025 10:06:44 +0200 Subject: [PATCH 037/173] Fix code quality --- osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 37b7d71d2b..578698b724 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -102,7 +102,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestStarQueriesInclusive() { - const string query = $"stars>=6"; + const string query = "stars>=6"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.AreEqual(filterCriteria.StarDifficulty.Min, 6.00d); From f4bf2ae7a5bbe1815e53c11c269add5fafbfcbc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 13 Jun 2025 10:33:54 +0200 Subject: [PATCH 038/173] Fix SHOCKING test What the actual heck is that magic number stupidity. I'm not looking into why the test falls over so badly that it apparently dies on some main menu logic just because assertions fail, because the main menu logic is for hold-to-song-select-v2 and is thus temporary. --- .../Visual/SongSelect/TestSceneBeatmapRecommendations.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 5c89e8a02c..d459eac3c2 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -64,16 +64,16 @@ namespace osu.Game.Tests.Visual.SongSelect switch (rulesetID) { case 0: - return 336; // recommended star rating of 2 + return 337; // recommended star rating of 2 case 1: return 973; // SR 3 case 2: - return 1905; // SR 4 + return 1906; // SR 4 case 3: - return 3329; // SR 5 + return 3330; // SR 5 default: return 0; From addd10f4c68e00667130c661dffd6f8ec8c7001b Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 13 Jun 2025 20:34:31 +0900 Subject: [PATCH 039/173] Rewrite `UpdateManager` to handle cancellations --- osu.Desktop/Updater/VelopackUpdateManager.cs | 186 ++++++++---------- .../NonVisual/TestSceneUpdateManager.cs | 59 +++--- .../TestSceneNotificationOverlay.cs | 7 +- osu.Game/Updater/MobileUpdateNotifier.cs | 8 +- osu.Game/Updater/NoActionUpdateManager.cs | 10 +- osu.Game/Updater/UpdateManager.cs | 141 ++++++------- 6 files changed, 191 insertions(+), 220 deletions(-) diff --git a/osu.Desktop/Updater/VelopackUpdateManager.cs b/osu.Desktop/Updater/VelopackUpdateManager.cs index 51744345a4..386a62d673 100644 --- a/osu.Desktop/Updater/VelopackUpdateManager.cs +++ b/osu.Desktop/Updater/VelopackUpdateManager.cs @@ -2,11 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Game; -using osu.Game.Configuration; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Screens.Play; @@ -28,132 +29,109 @@ namespace osu.Desktop.Updater private bool isInGameplay => localUserInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying; - private UpdateManager? updateManager; - private UpdateInfo? pendingUpdate; - private ReleaseStream? lastReleaseStream; + private ScheduledDelegate? scheduledBackgroundCheck; - protected override async Task PerformUpdateCheck() + private void scheduleNextUpdateCheck() { - if (ReleaseStream.Value != lastReleaseStream) + scheduledBackgroundCheck?.Cancel(); + scheduledBackgroundCheck = Scheduler.AddDelayed(() => { - updateManager = new UpdateManager(new GithubSource(@"https://github.com/ppy/osu", null, ReleaseStream.Value == Game.Configuration.ReleaseStream.Tachyon), new UpdateOptions - { - AllowVersionDowngrade = true, - }); - - lastReleaseStream = ReleaseStream.Value; - } - - return await checkForUpdateAsync().ConfigureAwait(false); + Logger.Log("Running scheduled background update check..."); + Task.Run(CheckForUpdateAsync); + }, 60000 * 30); } - private async Task checkForUpdateAsync() + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { - // whether to check again in 30 minutes. generally only if there's an error or no update was found (yet). - bool scheduleRecheck = false; + scheduledBackgroundCheck?.Cancel(); - try + if (isInGameplay) { - // Avoid any kind of update checking while gameplay is running. - if (isInGameplay) - { - scheduleRecheck = true; - return true; - } - - // TODO: we should probably be checking if there's a more recent update, rather than shortcutting here. - // Velopack does support this scenario (see https://github.com/ppy/osu/pull/28743#discussion_r1743495975). - if (pendingUpdate != null) - { - // If there is an update pending restart, show the notification to restart again. - notificationOverlay.Post(new UpdateApplicationCompleteNotification - { - Activated = () => - { - Task.Run(restartToApplyUpdate); - return true; - } - }); - - return true; - } - - if (updateManager == null) - { - scheduleRecheck = true; - return false; - } - - pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false); - - // No update is available. We'll check again later. - if (pendingUpdate == null) - { - scheduleRecheck = true; - return false; - } - - // An update is found, let's notify the user and start downloading it. - UpdateProgressNotification notification = new UpdateProgressNotification - { - CompletionClickAction = () => - { - Task.Run(restartToApplyUpdate); - return true; - }, - }; - - runOutsideOfGameplay(() => notificationOverlay.Post(notification)); - notification.StartDownload(); - - try - { - await updateManager.DownloadUpdatesAsync(pendingUpdate, p => notification.Progress = p / 100f).ConfigureAwait(false); - runOutsideOfGameplay(() => notification.State = ProgressNotificationState.Completed); - } - catch (Exception e) - { - // In the case of an error, a separate notification will be displayed. - scheduleRecheck = true; - notification.FailDownload(); - Logger.Error(e, @"update failed!"); - } - } - catch (Exception e) - { - // we'll ignore this and retry later. can be triggered by no internet connection or thread abortion. - scheduleRecheck = true; - Logger.Log($@"update check failed ({e.Message})"); - } - finally - { - if (scheduleRecheck) - { - Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30); - } + Logger.Log("Update check cancelled - user is in gameplay"); + scheduleNextUpdateCheck(); + return false; } + IUpdateSource updateSource = new GithubSource(@"https://github.com/ppy/osu", null, ReleaseStream.Value == Game.Configuration.ReleaseStream.Tachyon); + UpdateManager updateManager = new UpdateManager(updateSource, new UpdateOptions + { + AllowVersionDowngrade = true + }); + + UpdateInfo? update = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false); + + if (update == null) + { + // No update is available. + Logger.Log("No update found"); + scheduleNextUpdateCheck(); + return false; + } + + Logger.Log($"New update available: {update.TargetFullRelease.Version}"); + + // Download update in the background while notifying awaiters of the update being available. + downloadUpdate(updateManager, update, cancellationToken); return true; } - private void runOutsideOfGameplay(Action action) + private void downloadUpdate(UpdateManager updateManager, UpdateInfo update, CancellationToken cancellationToken) => Task.Run(async () => { + Logger.Log($"Beginning download of update {update.TargetFullRelease.Version}..."); + + UpdateDownloadProgressNotification progressNotification = new UpdateDownloadProgressNotification(cancellationToken) + { + CompletionClickAction = () => + { + restartToApplyUpdate(updateManager, update); + return true; + } + }; + + try + { + using (var cts = CancellationTokenSource.CreateLinkedTokenSource(progressNotification.CancellationToken, cancellationToken)) + { + progressNotification.StartDownload(); + runOutsideOfGameplay(() => notificationOverlay.Post(progressNotification), cts.Token); + + await updateManager.DownloadUpdatesAsync(update, p => progressNotification.Progress = p / 100f, false, cts.Token).ConfigureAwait(false); + runOutsideOfGameplay(() => progressNotification.State = ProgressNotificationState.Completed, cts.Token); + } + } + catch (OperationCanceledException) + { + progressNotification.FailDownload(); + Logger.Log(@"Update cancelled"); + } + catch (Exception e) + { + // In the case of an error, a separate notification will be displayed. + progressNotification.FailDownload(); + Logger.Error(e, @"Update failed!"); + } + + return true; + }, cancellationToken); + + private void runOutsideOfGameplay(Action action, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return; + if (isInGameplay) { - Scheduler.AddDelayed(() => runOutsideOfGameplay(action), 1000); + Scheduler.AddDelayed(() => runOutsideOfGameplay(action, cancellationToken), 1000); return; } action(); } - private async Task restartToApplyUpdate() + private void restartToApplyUpdate(UpdateManager updateManager, UpdateInfo update) => Task.Run(async () => { - if (updateManager == null) - return; - - await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false); + await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease).ConfigureAwait(false); Schedule(() => game.AttemptExit()); - } + }); } } diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs index fd2a3acb2f..385b8b82cb 100644 --- a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs +++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; @@ -69,21 +70,18 @@ namespace osu.Game.Tests.NonVisual } /// - /// Updates should be checked once more if the release stream is changed during an going check. + /// Changing the release stream should start a new invocation and cancel the existing one. /// [Test] - public void TestReleaseStreamChangedDuringCheck() + public void TestNewInvocationOnReleaseStreamChanged() { AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); AddUntilStep("check pending", () => manager.IsPending); AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer)); + AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3)); AddStep("complete check", () => manager.Complete()); AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); - - AddUntilStep("check pending", () => manager.IsPending); - AddStep("complete one check", () => manager.Complete()); - AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); AddUntilStep("no check pending", () => !manager.IsPending); } @@ -109,21 +107,18 @@ namespace osu.Game.Tests.NonVisual } /// - /// Any ongoing request should be returned when the user requests a new one. + /// User requests should start a new invocation and cancel the existing one. /// [Test] - public void TestUserRequestReturnsExistingCheck() + public void TestUserRequestOverridesExistingCheck() { - Task task1 = null!; - Task task2 = null!; - // This part covering double user input is not really possible because the settings button is disabled during the check, // but it's kept here for sanity in-case the update manager is used as a standalone object elsewhere. - AddStep("request check", () => task1 = manager.CheckForUpdateAsync()); + AddStep("request check", () => manager.CheckForUpdateAsync()); AddUntilStep("check pending", () => manager.IsPending); - AddStep("request check", () => task2 = manager.CheckForUpdateAsync()); - AddAssert("second request returned original task", () => task2 == task1); + AddStep("request check", () => manager.CheckForUpdateAsync()); + AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3)); AddStep("complete check", () => manager.Complete()); AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); @@ -133,12 +128,8 @@ namespace osu.Game.Tests.NonVisual AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); AddUntilStep("check pending", () => manager.IsPending); - AddStep("request check", () => - { - task1 = manager.CurrentTask; - task2 = manager.CheckForUpdateAsync(); - }); - AddAssert("second request returned original task", () => task2 == task1); + AddStep("request check", () => manager.CheckForUpdateAsync()); + AddUntilStep("5 invocations", () => manager.Invocations, () => Is.EqualTo(5)); AddStep("complete check", () => manager.Complete()); AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); @@ -148,28 +139,28 @@ namespace osu.Game.Tests.NonVisual private partial class TestUpdateManager : UpdateManager { public bool IsPending { get; private set; } + public int Invocations { get; private set; } public int Completions { get; private set; } - public Task CurrentTask { get; private set; } = Task.CompletedTask; - private TaskCompletionSource? pendingCheck; - protected override Task PerformUpdateCheck() + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { - var task = Task.Run(async () => + Invocations++; + + var check = pendingCheck = new TaskCompletionSource(); + IsPending = true; + + try { - var check = pendingCheck = new TaskCompletionSource(); - IsPending = true; - - bool result = await check.Task.ConfigureAwait(false); - IsPending = false; + bool result = await check.Task.WaitAsync(cancellationToken).ConfigureAwait(false); Completions++; - return result; - }); - - CurrentTask = task; - return task; + } + finally + { + IsPending = false; + } } public void Complete() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index 65c8b913d3..3648291816 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -456,7 +457,7 @@ namespace osu.Game.Tests.Visual.UserInterface { applyUpdate = false; - var updateNotification = new UpdateManager.UpdateProgressNotification + var updateNotification = new UpdateManager.UpdateDownloadProgressNotification(CancellationToken.None) { CompletionClickAction = () => applyUpdate = true }; @@ -468,9 +469,9 @@ namespace osu.Game.Tests.Visual.UserInterface checkProgressingCount(1); waitForCompletion(); - UpdateManager.UpdateApplicationCompleteNotification? completionNotification = null; + UpdateManager.UpdateReadyNotification? completionNotification = null; AddUntilStep("wait for completion notification", - () => (completionNotification = notificationOverlay.ChildrenOfType().SingleOrDefault()) != null); + () => (completionNotification = notificationOverlay.ChildrenOfType().SingleOrDefault()) != null); AddStep("click notification", () => completionNotification?.TriggerClick()); AddUntilStep("wait for update applied", () => applyUpdate); diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 04b54df3c0..02dac00cf4 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -4,13 +4,13 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Online.API; -using osu.Game.Overlays.Notifications; namespace osu.Game.Updater { @@ -31,13 +31,13 @@ namespace osu.Game.Updater version = game.Version; } - protected override async Task PerformUpdateCheck() + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { try { var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); - await releases.PerformAsync().ConfigureAwait(false); + await releases.PerformAsync(cancellationToken).ConfigureAwait(false); var latest = releases.ResponseObject; @@ -48,7 +48,7 @@ namespace osu.Game.Updater if (latestTagName != version && tryGetBestUrl(latest, out string? url)) { - Notifications.Post(new SimpleNotification + Notifications.Post(new UpdateAvailableNotification(cancellationToken) { Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" + "Click here to download the new version, which can be installed over the top of your existing installation", diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 4b8a3f000f..3f1e383a50 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -3,12 +3,11 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Online.API; -using osu.Game.Overlays.Notifications; namespace osu.Game.Updater { @@ -28,7 +27,7 @@ namespace osu.Game.Updater version = game.Version; } - protected override async Task PerformUpdateCheck() + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { try { @@ -36,7 +35,7 @@ namespace osu.Game.Updater bool includePrerelease = stream == Configuration.ReleaseStream.Tachyon; OsuJsonWebRequest releasesRequest = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases?per_page=10&page=1"); - await releasesRequest.PerformAsync().ConfigureAwait(false); + await releasesRequest.PerformAsync(cancellationToken).ConfigureAwait(false); GitHubRelease[] releases = releasesRequest.ResponseObject; GitHubRelease? latest = releases.OrderByDescending(r => r.PublishedAt).FirstOrDefault(r => includePrerelease || !r.Prerelease); @@ -51,11 +50,10 @@ namespace osu.Game.Updater if (latestTagName != version) { - Notifications.Post(new SimpleNotification + Notifications.Post(new UpdateAvailableNotification(cancellationToken) { Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" + "Check with your package manager / provider to bring osu! up-to-date!", - Icon = FontAwesome.Solid.Download, }); return true; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 279517db6c..3855ddd0d6 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Reflection; +using System.Threading; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; @@ -43,8 +44,6 @@ namespace osu.Game.Updater protected IBindable ReleaseStream => releaseStream; private readonly Bindable releaseStream = new Bindable(); - private bool updateCheckRequested; - private Task updateCheckTask = Task.FromResult(false); protected override void LoadComplete() { @@ -68,41 +67,12 @@ namespace osu.Game.Updater config.SetValue(OsuSetting.Version, version); config.BindWith(OsuSetting.ReleaseStream, releaseStream); - releaseStream.BindValueChanged(_ => scheduleUpdateCheck()); + releaseStream.BindValueChanged(_ => CheckForUpdateAsync()); - scheduleUpdateCheck(); + CheckForUpdateAsync(); } - protected override void Update() - { - base.Update(); - processScheduledUpdateCheck(); - } - - /// - /// Schedules a request to check for new updates to begin as soon as any existing check completes. - /// - private void scheduleUpdateCheck() - { - updateCheckRequested = true; - } - - /// - /// Processes an ongoing request to check for new updates. - /// - private void processScheduledUpdateCheck() - { - if (!updateCheckRequested) - return; - - if (!updateCheckTask.IsCompleted) - return; - - if (CanCheckForUpdate) - updateCheckTask = PerformUpdateCheck(); - - updateCheckRequested = false; - } + private CancellationTokenSource? updateCheckCancellation; /// /// Immediately checks for any available updates, or returns the existing update task. @@ -110,20 +80,22 @@ namespace osu.Game.Updater /// true if any updates are available, false otherwise. public Task CheckForUpdateAsync() { - if (!updateCheckTask.IsCompleted) - return updateCheckTask; - - scheduleUpdateCheck(); - processScheduledUpdateCheck(); - - return updateCheckTask; + updateCheckCancellation?.Cancel(); + updateCheckCancellation = new CancellationTokenSource(); + return PerformUpdateCheck(updateCheckCancellation.Token); } /// /// Performs an asynchronous check for application updates. /// /// Whether any update is waiting. May return true if an error occured (there is potentially an update available). - protected virtual Task PerformUpdateCheck() => Task.FromResult(false); + protected virtual Task PerformUpdateCheck(CancellationToken cancellationToken) => Task.FromResult(false); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + updateCheckCancellation?.Cancel(); + } private partial class UpdateCompleteNotification : SimpleNotification { @@ -150,20 +122,14 @@ namespace osu.Game.Updater } } - public partial class UpdateApplicationCompleteNotification : ProgressCompletionNotification + public partial class UpdateDownloadProgressNotification : ProgressNotification { - public UpdateApplicationCompleteNotification() - { - Text = NotificationsStrings.UpdateReadyToInstall; - } - } + private readonly CancellationToken cancellationToken; - public partial class UpdateProgressNotification : ProgressNotification - { - protected override Notification CreateCompletionNotification() => new UpdateApplicationCompleteNotification + public UpdateDownloadProgressNotification(CancellationToken cancellationToken) { - Activated = CompletionClickAction - }; + this.cancellationToken = cancellationToken; + } [BackgroundDependencyLoader] private void load() @@ -182,24 +148,12 @@ namespace osu.Game.Updater }); } - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); - StartDownload(); - } + base.Update(); - public override void Close(bool runFlingAnimation) - { - // cancelling updates is not currently supported by the underlying updater. - // only allow dismissing for now. - - switch (State) - { - case ProgressNotificationState.Cancelled: - case ProgressNotificationState.Completed: - base.Close(runFlingAnimation); - break; - } + if (cancellationToken.IsCancellationRequested) + FailDownload(); } public void StartDownload() @@ -214,6 +168,55 @@ namespace osu.Game.Updater State = ProgressNotificationState.Cancelled; Close(false); } + + protected override Notification CreateCompletionNotification() => new UpdateReadyNotification(cancellationToken) + { + Activated = () => + { + if (cancellationToken.IsCancellationRequested) + return true; + + return CompletionClickAction?.Invoke() ?? true; + } + }; + } + + public partial class UpdateReadyNotification : ProgressCompletionNotification + { + private readonly CancellationToken cancellationToken; + + public UpdateReadyNotification(CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + Text = NotificationsStrings.UpdateReadyToInstall; + } + + protected override void Update() + { + base.Update(); + + if (cancellationToken.IsCancellationRequested) + Close(false); + } + } + + public partial class UpdateAvailableNotification : SimpleNotification + { + private readonly CancellationToken cancellationToken; + + public UpdateAvailableNotification(CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + Icon = FontAwesome.Solid.Download; + } + + protected override void Update() + { + base.Update(); + + if (cancellationToken.IsCancellationRequested) + Close(false); + } } } } From 5f4a9d8e81722d5447a2b048c8b8f291b6d68a39 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 13 Jun 2025 20:47:06 +0900 Subject: [PATCH 040/173] Allow cancelling from initial notification --- osu.Desktop/Updater/VelopackUpdateManager.cs | 12 +++++-- .../Sections/General/UpdateSettings.cs | 7 ++-- osu.Game/Updater/UpdateManager.cs | 36 +++++++++++-------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/osu.Desktop/Updater/VelopackUpdateManager.cs b/osu.Desktop/Updater/VelopackUpdateManager.cs index 386a62d673..12a8b7a05d 100644 --- a/osu.Desktop/Updater/VelopackUpdateManager.cs +++ b/osu.Desktop/Updater/VelopackUpdateManager.cs @@ -37,7 +37,7 @@ namespace osu.Desktop.Updater scheduledBackgroundCheck = Scheduler.AddDelayed(() => { Logger.Log("Running scheduled background update check..."); - Task.Run(CheckForUpdateAsync); + CheckForUpdate(); }, 60000 * 30); } @@ -60,6 +60,13 @@ namespace osu.Desktop.Updater UpdateInfo? update = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + { + Logger.Log("Update check cancelled"); + scheduleNextUpdateCheck(); + return true; + } + if (update == null) { // No update is available. @@ -68,9 +75,8 @@ namespace osu.Desktop.Updater return false; } - Logger.Log($"New update available: {update.TargetFullRelease.Version}"); - // Download update in the background while notifying awaiters of the update being available. + Logger.Log($"New update available: {update.TargetFullRelease.Version}"); downloadUpdate(updateManager, update, cancellationToken); return true; } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index ac6215f3ad..b8a77a7688 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -126,7 +126,7 @@ namespace osu.Game.Overlays.Settings.Sections.General try { - bool foundUpdate = await updateManager.CheckForUpdateAsync().ConfigureAwait(true); + bool foundUpdate = await updateManager.CheckForUpdateAsync(checkingNotification.CancellationToken).ConfigureAwait(true); if (!foundUpdate) { @@ -142,8 +142,9 @@ namespace osu.Game.Overlays.Settings.Sections.General } finally { - // This sequence allows the notification to be immediately dismissed. - checkingNotification.State = ProgressNotificationState.Cancelled; + // This sequence allows the notification to be immediately dismissed without posting a continuation message. + checkingNotification.CompletionTarget = null; + checkingNotification.State = ProgressNotificationState.Completed; checkingNotification.Close(false); checkForUpdatesButton.Enabled.Value = true; } diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 3855ddd0d6..76557a4c77 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -44,6 +44,7 @@ namespace osu.Game.Updater protected IBindable ReleaseStream => releaseStream; private readonly Bindable releaseStream = new Bindable(); + private CancellationTokenSource updateCancellation = new CancellationTokenSource(); protected override void LoadComplete() { @@ -67,22 +68,35 @@ namespace osu.Game.Updater config.SetValue(OsuSetting.Version, version); config.BindWith(OsuSetting.ReleaseStream, releaseStream); - releaseStream.BindValueChanged(_ => CheckForUpdateAsync()); + releaseStream.BindValueChanged(_ => CheckForUpdate()); - CheckForUpdateAsync(); + CheckForUpdate(); } - private CancellationTokenSource? updateCheckCancellation; + /// + /// Immediately checks for any available update. + /// + public void CheckForUpdate() + { + _ = CheckForUpdateAsync(); + } /// - /// Immediately checks for any available updates, or returns the existing update task. + /// Immediately checks for any available update. /// /// true if any updates are available, false otherwise. - public Task CheckForUpdateAsync() + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) { - updateCheckCancellation?.Cancel(); - updateCheckCancellation = new CancellationTokenSource(); - return PerformUpdateCheck(updateCheckCancellation.Token); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var lastCancellation = Interlocked.Exchange(ref updateCancellation, cancellation); + + using (lastCancellation) + { + // This serves a dual purpose of nullifying the last update, closing any existing notifications as stale. + await lastCancellation.CancelAsync().ConfigureAwait(false); + } + + return await PerformUpdateCheck(cancellation.Token).ConfigureAwait(false); } /// @@ -91,12 +105,6 @@ namespace osu.Game.Updater /// Whether any update is waiting. May return true if an error occured (there is potentially an update available). protected virtual Task PerformUpdateCheck(CancellationToken cancellationToken) => Task.FromResult(false); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - updateCheckCancellation?.Cancel(); - } - private partial class UpdateCompleteNotification : SimpleNotification { private readonly string version; From 819decde761b9ab706d2479a67c210a530689ddd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 13 Jun 2025 22:13:07 +0900 Subject: [PATCH 041/173] Remove no-longer existing argument I'm not entirely sure how this works, but CI was testing against the updated Velopack package, whereas my local package was outdated and had 4 args. Previous commit merges master to update the package version, this commit fixes the args. --- osu.Desktop/Updater/VelopackUpdateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Desktop/Updater/VelopackUpdateManager.cs b/osu.Desktop/Updater/VelopackUpdateManager.cs index 12a8b7a05d..475d14e1d7 100644 --- a/osu.Desktop/Updater/VelopackUpdateManager.cs +++ b/osu.Desktop/Updater/VelopackUpdateManager.cs @@ -101,7 +101,7 @@ namespace osu.Desktop.Updater progressNotification.StartDownload(); runOutsideOfGameplay(() => notificationOverlay.Post(progressNotification), cts.Token); - await updateManager.DownloadUpdatesAsync(update, p => progressNotification.Progress = p / 100f, false, cts.Token).ConfigureAwait(false); + await updateManager.DownloadUpdatesAsync(update, p => progressNotification.Progress = p / 100f, cts.Token).ConfigureAwait(false); runOutsideOfGameplay(() => progressNotification.State = ProgressNotificationState.Completed, cts.Token); } } From 4f5c9f9713ac7632f503549b718aa91ef5169a6e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 13 Jun 2025 22:36:32 +0900 Subject: [PATCH 042/173] Resolve warnings in tests --- osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs index 385b8b82cb..bdb4dce354 100644 --- a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs +++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs @@ -91,14 +91,14 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestUserRequest() { - AddStep("request check", () => manager.CheckForUpdateAsync()); + AddStep("request check", () => manager.CheckForUpdate()); AddUntilStep("check pending", () => manager.IsPending); AddStep("complete check", () => manager.Complete()); AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); AddUntilStep("no check pending", () => !manager.IsPending); - AddStep("request check", () => manager.CheckForUpdateAsync()); + AddStep("request check", () => manager.CheckForUpdate()); AddUntilStep("check pending", () => manager.IsPending); AddStep("complete check", () => manager.Complete()); @@ -115,9 +115,9 @@ namespace osu.Game.Tests.NonVisual // This part covering double user input is not really possible because the settings button is disabled during the check, // but it's kept here for sanity in-case the update manager is used as a standalone object elsewhere. - AddStep("request check", () => manager.CheckForUpdateAsync()); + AddStep("request check", () => manager.CheckForUpdate()); AddUntilStep("check pending", () => manager.IsPending); - AddStep("request check", () => manager.CheckForUpdateAsync()); + AddStep("request check", () => manager.CheckForUpdate()); AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3)); AddStep("complete check", () => manager.Complete()); @@ -128,7 +128,7 @@ namespace osu.Game.Tests.NonVisual AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); AddUntilStep("check pending", () => manager.IsPending); - AddStep("request check", () => manager.CheckForUpdateAsync()); + AddStep("request check", () => manager.CheckForUpdate()); AddUntilStep("5 invocations", () => manager.Invocations, () => Is.EqualTo(5)); AddStep("complete check", () => manager.Complete()); From 3be57b90dba4d1a509cfee601f76d936a0f75bb9 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 13 Jun 2025 22:51:25 +0900 Subject: [PATCH 043/173] Dispose the last CTS --- osu.Game/Updater/UpdateManager.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index ef53148f67..d48d92bdae 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -105,6 +105,14 @@ namespace osu.Game.Updater /// Whether any update is waiting. May return true if an error occured (there is potentially an update available). protected virtual Task PerformUpdateCheck(CancellationToken cancellationToken) => Task.FromResult(false); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + updateCancellation.Cancel(); + updateCancellation.Dispose(); + } + private partial class UpdateCompleteNotification : SimpleNotification { private readonly string version; From 5ef3b372ae30b1010eed656a37eb01466adf5e6d Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 13 Jun 2025 22:54:34 +0900 Subject: [PATCH 044/173] Don't check for updates in DEBUG --- osu.Game/Updater/UpdateManager.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index d48d92bdae..ed19828998 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -87,6 +87,9 @@ namespace osu.Game.Updater /// true if any updates are available, false otherwise. public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) { + if (!CanCheckForUpdate) + return false; + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var lastCancellation = Interlocked.Exchange(ref updateCancellation, cancellation); From 27d4ad7991f239d0bc6055cffd36f57639eb2217 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 17 Jun 2025 19:41:05 +0900 Subject: [PATCH 045/173] Fix group selection acting weirdly when only one group is present --- .../TestSceneBeatmapCarouselDifficultyGrouping.cs | 15 +++++++++++++++ .../TestSceneBeatmapCarouselFiltering.cs | 4 ++-- .../TestSceneBeatmapCarouselNoGrouping.cs | 7 ++----- osu.Game/Graphics/Carousel/Carousel.cs | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs index 5a03e05344..2ab0eda172 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs @@ -155,6 +155,21 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkGroupKeyboardSelected(1); } + [Test] + public void TestKeyboardGroupTraversalSingleGroup() + { + RemoveAllBeatmaps(); + AddBeatmaps(1, 1); + + WaitForBeatmapSelection(0, 0); + + SelectNextGroup(); + checkBeatmapIsKeyboardSelected(); + + SelectPrevGroup(); + checkBeatmapIsKeyboardSelected(); + } + [Test] public void TestKeyboardGroupTraversal() { diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs index 8ed1b1745e..78c12e2730 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs @@ -313,9 +313,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 CheckDisplayedBeatmapsCount(4); SelectNextSet(); - WaitForSetSelection(0, 1); - SelectPrevSet(); WaitForSetSelection(1, 1); + SelectPrevSet(); + WaitForSetSelection(0, 1); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs index a6ba6d76a3..c3617b9f16 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs @@ -195,16 +195,13 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SelectNextSet(); WaitForSetSelection(0, 0); - // In the case of a grouped beatmap set, the header gets activated and re-selects the recommended difficulty. - // This is probably fine. - CheckActivationCount(1); - // We don't want it to request present though, which would start gameplay. + CheckActivationCount(0); CheckRequestPresentCount(0); SelectPrevSet(); WaitForSetSelection(0, 0); - CheckActivationCount(1); + CheckActivationCount(0); CheckRequestPresentCount(0); } diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 231b2958c6..a4aafb269e 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -616,7 +616,7 @@ namespace osu.Game.Graphics.Carousel var newItem = carouselItems[newIndex]; - if (predicate(newItem)) + if (!newItem.IsExpanded && predicate(newItem)) { Activate(newItem); return; From b74db44fdeecfcff4141ca3b56701cf48896879f Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 17 Jun 2025 23:15:17 +0900 Subject: [PATCH 046/173] Fix group toggle not working as expected anymore --- osu.Game/Graphics/Carousel/Carousel.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index a4aafb269e..545fac0e98 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -491,8 +491,8 @@ namespace osu.Game.Graphics.Carousel } else { - // If current keyboard selection is not a group, toggle closest group and move keyboard selection to that group. - traverseSelection(-1, CheckValidForGroupSelection, false); + // If current keyboard selection is not a group, toggle the closest group and move keyboard selection to that group. + traverseSelection(-1, CheckValidForGroupSelection, skipFirst: false, activateExpandedItems: true); } return true; @@ -577,7 +577,7 @@ namespace osu.Game.Graphics.Carousel traverseSelection(direction, CheckValidForSetSelection); } - private void traverseSelection(int direction, Func predicate, bool skipFirst = true) + private void traverseSelection(int direction, Func predicate, bool skipFirst = true, bool activateExpandedItems = false) { if (carouselItems == null || carouselItems.Count == 0) return; @@ -616,7 +616,7 @@ namespace osu.Game.Graphics.Carousel var newItem = carouselItems[newIndex]; - if (!newItem.IsExpanded && predicate(newItem)) + if ((activateExpandedItems || !newItem.IsExpanded) && predicate(newItem)) { Activate(newItem); return; From 1ceb59d78e01fcad369c62e842a1c144c5f68df0 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Wed, 18 Jun 2025 01:52:26 +0300 Subject: [PATCH 047/173] Adjust rank formatting logic to avoid getting cut in score --- .../Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs | 3 ++- osu.Game/Utils/FormatUtils.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs index 7ef8da7673..0aca2d6a1c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs @@ -53,6 +53,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { fillFlow = new FillFlowContainer { + X = 100, Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, @@ -281,7 +282,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }, new ScoreInfo { - Position = 110000, + Position = 2233, Rank = ScoreRank.D, Accuracy = 1, MaxCombo = 244, diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index f7250c6833..122bc6d326 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -30,7 +30,7 @@ namespace osu.Game.Utils /// Formats the supplied rank/leaderboard position in a consistent, simplified way. /// /// The rank/position to be formatted. - public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 100_000 ? 1 : 0); + public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 10_000 ? 1 : 0); /// /// Formats the supplied star rating in a consistent, simplified way. From f48cfa7ef6899e6b9083cc8c8ceb107236636950 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 18 Jun 2025 14:27:17 +0900 Subject: [PATCH 048/173] Adjust some button's hover colours to improve visual contrast with text Addresses https://github.com/ppy/osu/discussions/33722. --- osu.Game/Graphics/UserInterfaceV2/FormButton.cs | 2 +- osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs index 1c5d4b5d80..85198191b8 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs @@ -129,7 +129,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void load(OverlayColourProvider overlayColourProvider) { DefaultBackgroundColour = overlayColourProvider.Colour3; - triangleGradientSecondColour ??= overlayColourProvider.Colour1; + triangleGradientSecondColour ??= DefaultBackgroundColour.Lighten(0.2f); if (Text == default) { diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index 9b57ebb200..bf92f20526 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -54,7 +54,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { // Many buttons have local colours, but this provides a sane default for all other cases. DefaultBackgroundColour = overlayColourProvider?.Colour3 ?? colours.Blue3; - triangleGradientSecondColour ??= overlayColourProvider?.Colour1 ?? colours.Blue3.Lighten(0.2f); + triangleGradientSecondColour ??= DefaultBackgroundColour.Lighten(0.2f); } protected override void LoadComplete() From eb8c4a27e5367eca2cccdb1cf4150cb6cba5dfa2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 18 Jun 2025 18:29:36 +0900 Subject: [PATCH 049/173] Update cancellation token naming / inline comment slightly --- osu.Game/Updater/UpdateManager.cs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index ed19828998..a9b00e8f93 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -44,7 +44,8 @@ namespace osu.Game.Updater protected IBindable ReleaseStream => releaseStream; private readonly Bindable releaseStream = new Bindable(); - private CancellationTokenSource updateCancellation = new CancellationTokenSource(); + + private CancellationTokenSource updateCancellationSource = new CancellationTokenSource(); protected override void LoadComplete() { @@ -90,16 +91,13 @@ namespace osu.Game.Updater if (!CanCheckForUpdate) return false; - var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var lastCancellation = Interlocked.Exchange(ref updateCancellation, cancellation); + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using (lastCancellation) - { - // This serves a dual purpose of nullifying the last update, closing any existing notifications as stale. - await lastCancellation.CancelAsync().ConfigureAwait(false); - } + // Cancels the last update and closes any existing notifications as stale. + using (var lastCts = Interlocked.Exchange(ref updateCancellationSource, cts)) + await lastCts.CancelAsync().ConfigureAwait(false); - return await PerformUpdateCheck(cancellation.Token).ConfigureAwait(false); + return await PerformUpdateCheck(cts.Token).ConfigureAwait(false); } /// @@ -112,8 +110,8 @@ namespace osu.Game.Updater { base.Dispose(isDisposing); - updateCancellation.Cancel(); - updateCancellation.Dispose(); + updateCancellationSource.Cancel(); + updateCancellationSource.Dispose(); } private partial class UpdateCompleteNotification : SimpleNotification From 938a3cf3eb56738ffba63ee5708d0f1f5e63a8a2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 18 Jun 2025 18:40:54 +0900 Subject: [PATCH 050/173] Add prefix to log events --- osu.Desktop/Updater/VelopackUpdateManager.cs | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/osu.Desktop/Updater/VelopackUpdateManager.cs b/osu.Desktop/Updater/VelopackUpdateManager.cs index 475d14e1d7..3b79313f8c 100644 --- a/osu.Desktop/Updater/VelopackUpdateManager.cs +++ b/osu.Desktop/Updater/VelopackUpdateManager.cs @@ -13,10 +13,11 @@ using osu.Game.Overlays.Notifications; using osu.Game.Screens.Play; using Velopack; using Velopack.Sources; +using UpdateManager = osu.Game.Updater.UpdateManager; namespace osu.Desktop.Updater { - public partial class VelopackUpdateManager : Game.Updater.UpdateManager + public partial class VelopackUpdateManager : UpdateManager { [Resolved] private INotificationOverlay notificationOverlay { get; set; } = null!; @@ -36,7 +37,7 @@ namespace osu.Desktop.Updater scheduledBackgroundCheck?.Cancel(); scheduledBackgroundCheck = Scheduler.AddDelayed(() => { - Logger.Log("Running scheduled background update check..."); + log("Running scheduled background update check..."); CheckForUpdate(); }, 60000 * 30); } @@ -47,13 +48,13 @@ namespace osu.Desktop.Updater if (isInGameplay) { - Logger.Log("Update check cancelled - user is in gameplay"); + log("Update check cancelled - user is in gameplay"); scheduleNextUpdateCheck(); return false; } IUpdateSource updateSource = new GithubSource(@"https://github.com/ppy/osu", null, ReleaseStream.Value == Game.Configuration.ReleaseStream.Tachyon); - UpdateManager updateManager = new UpdateManager(updateSource, new UpdateOptions + Velopack.UpdateManager updateManager = new Velopack.UpdateManager(updateSource, new UpdateOptions { AllowVersionDowngrade = true }); @@ -62,7 +63,7 @@ namespace osu.Desktop.Updater if (cancellationToken.IsCancellationRequested) { - Logger.Log("Update check cancelled"); + log("Update check cancelled"); scheduleNextUpdateCheck(); return true; } @@ -70,20 +71,20 @@ namespace osu.Desktop.Updater if (update == null) { // No update is available. - Logger.Log("No update found"); + log("No update found"); scheduleNextUpdateCheck(); return false; } // Download update in the background while notifying awaiters of the update being available. - Logger.Log($"New update available: {update.TargetFullRelease.Version}"); + log($"New update available: {update.TargetFullRelease.Version}"); downloadUpdate(updateManager, update, cancellationToken); return true; } - private void downloadUpdate(UpdateManager updateManager, UpdateInfo update, CancellationToken cancellationToken) => Task.Run(async () => + private void downloadUpdate(Velopack.UpdateManager updateManager, UpdateInfo update, CancellationToken cancellationToken) => Task.Run(async () => { - Logger.Log($"Beginning download of update {update.TargetFullRelease.Version}..."); + log($"Beginning download of update {update.TargetFullRelease.Version}..."); UpdateDownloadProgressNotification progressNotification = new UpdateDownloadProgressNotification(cancellationToken) { @@ -108,7 +109,7 @@ namespace osu.Desktop.Updater catch (OperationCanceledException) { progressNotification.FailDownload(); - Logger.Log(@"Update cancelled"); + log(@"Update cancelled"); } catch (Exception e) { @@ -134,10 +135,12 @@ namespace osu.Desktop.Updater action(); } - private void restartToApplyUpdate(UpdateManager updateManager, UpdateInfo update) => Task.Run(async () => + private void restartToApplyUpdate(Velopack.UpdateManager updateManager, UpdateInfo update) => Task.Run(async () => { await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease).ConfigureAwait(false); Schedule(() => game.AttemptExit()); }); + + private static void log(string text) => Logger.Log($"VelopackUpdateManager: {text}"); } } From 176a85763ce47d56132661b9a6a0feded98e7837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 18 Jun 2025 17:59:54 +0200 Subject: [PATCH 051/173] Fix drawable hold notes continuing to show hit lighting with No Release mod and classic skin Closes https://github.com/ppy/osu/issues/33751. --- .../Objects/Drawables/DrawableHoldNote.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 6c607886ae..23c062164e 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -197,6 +197,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public override void OnKilled() { base.OnKilled(); + // flush the final state of holding on kill. + // this matters because some skin implementations like legacy skin + // insert drawables in the hierarchy that are not a child of this DHO + // (see `LegacyBodyPiece` and related machinations with `lightContainer` being added at column level) + isHolding.Value = Result.IsHolding(Time.Current); (bodyPiece.Drawable as IHoldNoteBody)?.Recycle(); } From 9cb824df6f299d3f8b04753ca6cb9100084a2315 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Wed, 18 Jun 2025 19:17:15 +0300 Subject: [PATCH 052/173] Add failing test case --- .../Mods/OsuModMagnetised.cs | 2 +- .../TestSceneSongSelectNavigation.cs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index b2553e295c..5038250261 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -19,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + public class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset { public override string Name => "Magnetised"; public override string Acronym => "MG"; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs index 8dc73af108..14dbd7981c 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs @@ -10,6 +10,7 @@ using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Overlays; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens; @@ -126,6 +127,47 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); } + [Test] + public void TestAutoplayShortcutReturnsInitialModsOnExit() + { + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + + AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); + + AddStep("open mod select", () => InputManager.Key(Key.F1)); + AddStep("search magnetised", () => this.ChildrenOfType().Single().SearchTerm = "MG"); + AddStep("select", () => InputManager.Key(Key.Enter)); + + AddAssert("magnetised selected", () => Game.SelectedMods.Value.Single(), Is.TypeOf); + AddStep("configure mod", () => ((OsuModMagnetised)Game.SelectedMods.Value.Single()).AttractionStrength.Value = 1.0f); + + pushEscape(); + pushEscape(); + + AddStep("press ctrl+enter", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Enter); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + AddAssert("only autoplay selected", () => Game.SelectedMods.Value.Single(), Is.TypeOf); + + pushEscape(); + waitForScreen(); + + AddAssert("magnetised selected", () => Game.SelectedMods.Value.Single(), Is.TypeOf); + AddAssert("mod configured", () => ((OsuModMagnetised)Game.SelectedMods.Value.Single()).AttractionStrength.Value, () => Is.EqualTo(1.0f)); + } + private Func playToResults() { var player = playToCompletion(); From ce498c9062c55a504381ffa5fd878073a355177e Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Wed, 18 Jun 2025 19:27:32 +0300 Subject: [PATCH 053/173] Deep clone mods when temporarily activating autoplay --- osu.Game/Screens/SelectV2/SoloSongSelect.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/SelectV2/SoloSongSelect.cs index 3771528a80..4ef73d4c49 100644 --- a/osu.Game/Screens/SelectV2/SoloSongSelect.cs +++ b/osu.Game/Screens/SelectV2/SoloSongSelect.cs @@ -91,7 +91,7 @@ namespace osu.Game.Screens.SelectV2 { if (playerLoader != null) return; - modsAtGameplayStart = Mods.Value; + modsAtGameplayStart = Mods.Value.Select(m => m.DeepClone()).ToArray(); // Ctrl+Enter should start map with autoplay enabled. if (GetContainingInputManager()?.CurrentState?.Keyboard.ControlPressed == true) From 634fd007e1dfcf6580382794c293525e8e507e6b Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 19 Jun 2025 15:08:42 +0900 Subject: [PATCH 054/173] Ignore case when parsing `OSU_EXTERNAL_UPDATE_STREAM` --- osu.Game/Updater/NoActionUpdateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 3f1e383a50..06189b488c 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -17,7 +17,7 @@ namespace osu.Game.Updater /// public partial class NoActionUpdateManager : UpdateManager { - private static ReleaseStream? externalReleaseStream => Enum.TryParse(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_STREAM"), out ReleaseStream stream) ? stream : null; + private static ReleaseStream? externalReleaseStream => Enum.TryParse(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_STREAM"), true, out ReleaseStream stream) ? stream : null; private string version = string.Empty; From d200a5990213e1568cb9e9d00da201e1972b3e52 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 18 Jun 2025 19:18:44 +0900 Subject: [PATCH 055/173] SongSelectV2: Fix random button not respecting open group Closes https://github.com/ppy/osu/issues/33569. --- .../TestSceneBeatmapCarouselRandom.cs | 74 ++++++++++++++----- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 20 +++++ 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs index 858c314904..17d31634fc 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; namespace osu.Game.Tests.Visual.SongSelectV2 { @@ -47,25 +48,42 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddBeatmaps(10, 3, true); WaitForDrawablePanels(); + GroupDefinition? expanded = null; + + for (int i = 0; i < 2; i++) + { + nextRandom(); + expanded ??= storeExpandedGroup(); + + ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); + } + nextRandom(); - ensureRandomDidNotRepeat(); - nextRandom(); - ensureRandomDidNotRepeat(); - nextRandom(); - ensureRandomDidNotRepeat(); + ensureRandomDidRepeat(); + checkExpandedGroupUnchanged(); prevRandom(); checkRewindCorrectSet(); + checkExpandedGroupUnchanged(); prevRandom(); checkRewindCorrectSet(); + checkExpandedGroupUnchanged(); nextRandom(); ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); nextRandom(); - ensureRandomDidNotRepeat(); + ensureRandomDidRepeat(); + checkExpandedGroupUnchanged(); - nextRandom(); - AddAssert("ensure repeat", () => BeatmapSetRequestedSelections.Contains(Carousel.SelectedBeatmapSet!)); + GroupDefinition? storeExpandedGroup() + { + AddStep("store open group", () => expanded = Carousel.ExpandedGroup); + return null; + } + + void checkExpandedGroupUnchanged() => AddAssert("expanded did not change", () => Carousel.ExpandedGroup, () => Is.EqualTo(expanded)); } /// @@ -76,28 +94,47 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); - AddBeatmaps(10, 3, true); + AddBeatmaps(3, 3, true); WaitForDrawablePanels(); + GroupDefinition? expanded = null; + + for (int i = 0; i < 3; i++) + { + nextRandom(); + expanded ??= storeExpandedGroup(); + + ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); + } + nextRandom(); - ensureRandomDidNotRepeat(); - nextRandom(); - ensureRandomDidNotRepeat(); - nextRandom(); - ensureRandomDidNotRepeat(); + ensureRandomDidRepeat(); + checkExpandedGroupUnchanged(); prevRandom(); checkRewindCorrectSet(); + checkExpandedGroupUnchanged(); + prevRandom(); checkRewindCorrectSet(); + checkExpandedGroupUnchanged(); nextRandom(); ensureRandomDidNotRepeat(); - nextRandom(); - ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); nextRandom(); - AddAssert("ensure repeat", () => BeatmapSetRequestedSelections.Contains(Carousel.SelectedBeatmapSet!)); + ensureRandomDidRepeat(); + checkExpandedGroupUnchanged(); + + GroupDefinition? storeExpandedGroup() + { + AddStep("store open group", () => expanded = Carousel.ExpandedGroup); + return null; + } + + void checkExpandedGroupUnchanged() => AddAssert("expanded did not change", () => Carousel.ExpandedGroup, () => Is.EqualTo(expanded)); } [Test] @@ -174,6 +211,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private void nextRandom() => AddStep("select random next", () => Carousel.NextRandom()); + private void ensureRandomDidRepeat() => + AddAssert("did repeat", () => BeatmapSetRequestedSelections.Distinct().Count(), () => Is.LessThan(BeatmapSetRequestedSelections.Count)); + private void ensureRandomDidNotRepeat() => AddAssert("no repeats", () => BeatmapSetRequestedSelections.Distinct().Count(), () => Is.EqualTo(BeatmapSetRequestedSelections.Count)); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 24092b8ecd..077a0fb9f8 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -634,6 +634,26 @@ namespace osu.Game.Screens.SelectV2 // This is the fastest way to retrieve sets for randomisation. ICollection visibleSets = grouping.SetItems.Keys; + if (ExpandedGroup != null) + { + // In the case of grouping, users expect random to only operate on the expanded group. + // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. + // + // If this becomes an issue, we could either store a mapping, or run the random algorithm many times + // using the `SetItems` method until we get a group HIT. + if (grouping.BeatmapSetsGroupedTogether) + visibleSets = grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().ToArray(); + else + { + // Note that this is probably not correct in all cases. + // When we aren't grouping sets together, we might want to randomise by beatmaps, not sets. + // + // Imagine the scenario where a single beatmap set has multiple difficulties in the same difficulty grouping, where this + // would always choose the set's user recommended difficulty rather than the visible ones. + visibleSets = grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().Select(b => b.BeatmapSet!).Distinct().ToArray(); + } + } + if (CurrentSelection is BeatmapInfo beatmapInfo) { randomSelectedBeatmaps.Add(beatmapInfo); From f676206331cfa43485f6b62f81282eefc50b3ae6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 18 Jun 2025 19:42:13 +0900 Subject: [PATCH 056/173] SongSelectV2: Fix random selection not working as expected when displaying individual difficulties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, random selection would always be done at a *set* level. The final operation of a random action would be "select the user's recommended difficulty from this randomly selected set". This makes no sense when sets are not grouped together at song select. In fact, it is completely broken with the previous commit which adds group-isolated random support – if we're grouping by difficulty and the user's recommendation is not in the current group it would throw the user into another group unexpectedly. This fixes the issue by splitting out the random implementation into two separate pathways depending on the carousel display mode. --- .../SongSelectV2/BeatmapCarouselTestScene.cs | 3 + .../TestSceneBeatmapCarouselRandom.cs | 113 ++++++++++--- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 150 +++++++++++++----- 3 files changed, 201 insertions(+), 65 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index 3943b13286..e6ba6a904d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -36,6 +36,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public abstract partial class BeatmapCarouselTestScene : OsuManualInputManagerTestScene { protected readonly Stack BeatmapSetRequestedSelections = new Stack(); + protected readonly Stack BeatmapRequestedSelections = new Stack(); protected readonly BindableList BeatmapSets = new BindableList(); @@ -73,6 +74,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { AddStep("create components", () => { + BeatmapRequestedSelections.Clear(); BeatmapSetRequestedSelections.Clear(); BeatmapRecommendationFunction = null; NewItemsPresentedInvocationCount = 0; @@ -113,6 +115,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 NewItemsPresented = _ => NewItemsPresentedInvocationCount++, RequestSelection = b => { + BeatmapRequestedSelections.Push(b); Carousel.CurrentSelection = b; }, RequestRecommendedSelection = beatmaps => diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs index 17d31634fc..739fc23ed5 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs @@ -55,26 +55,26 @@ namespace osu.Game.Tests.Visual.SongSelectV2 nextRandom(); expanded ??= storeExpandedGroup(); - ensureRandomDidNotRepeat(); + ensureSetRandomDidNotRepeat(); checkExpandedGroupUnchanged(); } nextRandom(); - ensureRandomDidRepeat(); + ensureSetRandomDidRepeat(); checkExpandedGroupUnchanged(); - prevRandom(); + prevRandomSet(); checkRewindCorrectSet(); checkExpandedGroupUnchanged(); - prevRandom(); + prevRandomSet(); checkRewindCorrectSet(); checkExpandedGroupUnchanged(); nextRandom(); - ensureRandomDidNotRepeat(); + ensureSetRandomDidNotRepeat(); checkExpandedGroupUnchanged(); nextRandom(); - ensureRandomDidRepeat(); + ensureSetRandomDidRepeat(); checkExpandedGroupUnchanged(); GroupDefinition? storeExpandedGroup() @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 /// Test random non-repeating algorithm /// [Test] - public void TestRandomDifficultyGrouping() + public void TestRandomDifficultyGroupingRewindsCorrectly() { SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); @@ -108,21 +108,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkExpandedGroupUnchanged(); } - nextRandom(); - ensureRandomDidRepeat(); - checkExpandedGroupUnchanged(); + for (int i = 0; i < 2; i++) + { + prevRandom(); + checkRewindCorrect(); + checkExpandedGroupUnchanged(); + } - prevRandom(); - checkRewindCorrectSet(); - checkExpandedGroupUnchanged(); - - prevRandom(); - checkRewindCorrectSet(); - checkExpandedGroupUnchanged(); - - nextRandom(); - ensureRandomDidNotRepeat(); - checkExpandedGroupUnchanged(); + for (int i = 0; i < 2; i++) + { + nextRandom(); + ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); + } nextRandom(); ensureRandomDidRepeat(); @@ -137,6 +135,54 @@ namespace osu.Game.Tests.Visual.SongSelectV2 void checkExpandedGroupUnchanged() => AddAssert("expanded did not change", () => Carousel.ExpandedGroup, () => Is.EqualTo(expanded)); } + /// + /// Test random non-repeating algorithm + /// + [Test] + public void TestRandomDifficultyGroupingRepeatsWhenExhausted() + { + SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); + + AddBeatmaps(3, 3, true); + WaitForDrawablePanels(); + + GroupDefinition? expanded = null; + + for (int i = 0; i < 3; i++) + { + nextRandom(); + expanded ??= storeExpandedGroup(); + + ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); + } + + for (int i = 0; i < 3; i++) + { + nextRandom(); + ensureRandomDidRepeat(); + } + + for (int i = 0; i < 5; i++) + { + prevRandom(); + checkRewindCorrect(); + checkExpandedGroupUnchanged(); + } + + nextRandom(); + checkExpandedGroupUnchanged(); + // can't assert repeat or otherwise as we went through multiple permutations. + + GroupDefinition? storeExpandedGroup() + { + AddStep("store open group", () => expanded = Carousel.ExpandedGroup); + return null; + } + + void checkExpandedGroupUnchanged() => AddAssert("expanded did not change", () => Carousel.ExpandedGroup, () => Is.EqualTo(expanded)); + } + [Test] public void TestRewindOverMultipleIterations() { @@ -153,7 +199,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 for (int i = 0; i < random_select_count; i++) { - prevRandom(); + prevRandomSet(); checkRewindCorrectSet(); } } @@ -204,7 +250,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(postRandomSelection)); - prevRandom(); + prevRandomSet(); AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(postRandomSelection)); } @@ -212,15 +258,32 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("select random next", () => Carousel.NextRandom()); private void ensureRandomDidRepeat() => - AddAssert("did repeat", () => BeatmapSetRequestedSelections.Distinct().Count(), () => Is.LessThan(BeatmapSetRequestedSelections.Count)); + AddAssert("did repeat", () => BeatmapRequestedSelections.Distinct().Count(), () => Is.LessThan(BeatmapRequestedSelections.Count)); private void ensureRandomDidNotRepeat() => + AddAssert("no repeats", () => BeatmapRequestedSelections.Distinct().Count(), () => Is.EqualTo(BeatmapRequestedSelections.Count)); + + private void ensureSetRandomDidRepeat() => + AddAssert("did repeat", () => BeatmapSetRequestedSelections.Distinct().Count(), () => Is.LessThan(BeatmapSetRequestedSelections.Count)); + + private void ensureSetRandomDidNotRepeat() => AddAssert("no repeats", () => BeatmapSetRequestedSelections.Distinct().Count(), () => Is.EqualTo(BeatmapSetRequestedSelections.Count)); + private void checkRewindCorrect() => + AddAssert("rewind matched expected beatmap", () => BeatmapRequestedSelections.Peek(), () => Is.EqualTo(Carousel.SelectedBeatmapInfo)); + private void checkRewindCorrectSet() => AddAssert("rewind matched expected set", () => BeatmapSetRequestedSelections.Peek(), () => Is.EqualTo(Carousel.SelectedBeatmapSet)); - private void prevRandom() => AddStep("select random last", () => + private void prevRandom() => AddStep("select last random", () => + { + Carousel.PreviousRandom(); + BeatmapRequestedSelections.Pop(); + // Pop twice because the PreviousRandom call also requests selection. + BeatmapRequestedSelections.Pop(); + }); + + private void prevRandomSet() => AddStep("select last random set", () => { Carousel.PreviousRandom(); BeatmapSetRequestedSelections.Pop(); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 077a0fb9f8..cd5ab68e6f 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -618,8 +618,8 @@ namespace osu.Game.Screens.SelectV2 #region Random selection handling private readonly Bindable randomAlgorithm = new Bindable(); - private readonly List previouslyVisitedRandomSets = new List(); - private readonly List randomSelectedBeatmaps = new List(); + private readonly List previouslyVisitedRandomBeatmaps = new List(); + private readonly List randomHistory = new List(); private Sample? spinSample; private Sample? randomSelectSample; @@ -631,59 +631,129 @@ namespace osu.Game.Screens.SelectV2 if (carouselItems?.Any() != true) return false; - // This is the fastest way to retrieve sets for randomisation. - ICollection visibleSets = grouping.SetItems.Keys; + var selectionBefore = CurrentSelectionItem; + var beatmapBefore = selectionBefore?.Model as BeatmapInfo; - if (ExpandedGroup != null) + bool success; + + if (beatmapBefore != null) { + // keep track of visited beatmaps and sets for rewind + randomHistory.Add(beatmapBefore); + // keep track of visited beatmaps for "RandomPermutation" random tracking. + // note that this is reset when we run out of beatmaps, while `randomHistory` is not. + previouslyVisitedRandomBeatmaps.Add(beatmapBefore); + } + + if (grouping.BeatmapSetsGroupedTogether) + success = nextRandomSet(); + else + success = nextRandomBeatmap(); + + if (!success) + { + if (beatmapBefore != null) + randomHistory.RemoveAt(randomHistory.Count - 1); + return false; + } + + Scheduler.Add(() => + { + if (selectionBefore != null && CurrentSelectionItem != null) + playSpinSample(distanceBetween(selectionBefore, CurrentSelectionItem), carouselItems.Count); + }); + + return true; + } + + private bool nextRandomBeatmap() + { + ICollection visibleBeatmaps = ExpandedGroup != null // In the case of grouping, users expect random to only operate on the expanded group. // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. // // If this becomes an issue, we could either store a mapping, or run the random algorithm many times // using the `SetItems` method until we get a group HIT. - if (grouping.BeatmapSetsGroupedTogether) - visibleSets = grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().ToArray(); - else - { - // Note that this is probably not correct in all cases. - // When we aren't grouping sets together, we might want to randomise by beatmaps, not sets. - // - // Imagine the scenario where a single beatmap set has multiple difficulties in the same difficulty grouping, where this - // would always choose the set's user recommended difficulty rather than the visible ones. - visibleSets = grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().Select(b => b.BeatmapSet!).Distinct().ToArray(); - } - } + ? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().ToArray() + : GetCarouselItems()!.Select(i => i.Model).OfType().ToArray(); - if (CurrentSelection is BeatmapInfo beatmapInfo) + BeatmapInfo beatmap; + + switch (randomAlgorithm.Value) { - randomSelectedBeatmaps.Add(beatmapInfo); + case RandomSelectAlgorithm.RandomPermutation: + { + ICollection notYetVisitedBeatmaps = visibleBeatmaps.Except(previouslyVisitedRandomBeatmaps).ToList(); - // when performing a random, we want to add the current set to the previously visited list - // else the user may be "randomised" to the existing selection. - if (previouslyVisitedRandomSets.LastOrDefault()?.Equals(beatmapInfo.BeatmapSet) != true) - previouslyVisitedRandomSets.Add(beatmapInfo.BeatmapSet!); + if (!notYetVisitedBeatmaps.Any()) + { + previouslyVisitedRandomBeatmaps.RemoveAll(b => visibleBeatmaps.Contains(b)); + notYetVisitedBeatmaps = visibleBeatmaps; + if (CurrentSelection is BeatmapInfo beatmapInfo) + notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([beatmapInfo]).ToList(); + } + + if (notYetVisitedBeatmaps.Count == 0) + return false; + + beatmap = notYetVisitedBeatmaps.ElementAt(RNG.Next(notYetVisitedBeatmaps.Count)); + break; + } + + case RandomSelectAlgorithm.Random: + beatmap = visibleBeatmaps.ElementAt(RNG.Next(visibleBeatmaps.Count)); + break; + + default: + throw new ArgumentOutOfRangeException(); } + RequestSelection(beatmap); + return true; + } + + private bool nextRandomSet() + { + ICollection visibleSets = ExpandedGroup != null + // In the case of grouping, users expect random to only operate on the expanded group. + // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. + // + // If this becomes an issue, we could either store a mapping, or run the random algorithm many times + // using the `SetItems` method until we get a group HIT. + ? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType().ToArray() + // This is the fastest way to retrieve sets for randomisation. + : grouping.SetItems.Keys; + BeatmapSetInfo set; - if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) + switch (randomAlgorithm.Value) { - ICollection notYetVisitedSets = visibleSets.Except(previouslyVisitedRandomSets).ToList(); - - if (!notYetVisitedSets.Any()) + case RandomSelectAlgorithm.RandomPermutation: { - previouslyVisitedRandomSets.RemoveAll(s => visibleSets.Contains(s)); - notYetVisitedSets = visibleSets; + ICollection notYetVisitedSets = visibleSets.Except(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!)).ToList(); + + if (!notYetVisitedSets.Any()) + { + previouslyVisitedRandomBeatmaps.RemoveAll(b => visibleSets.Contains(b.BeatmapSet!)); + notYetVisitedSets = visibleSets; + if (CurrentSelection is BeatmapInfo beatmapInfo) + notYetVisitedSets = notYetVisitedSets.Except([beatmapInfo.BeatmapSet!]).ToList(); + } + + if (notYetVisitedSets.Count == 0) + return false; + + set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count)); + break; } - set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count)); - previouslyVisitedRandomSets.Add(set); - } - else - set = visibleSets.ElementAt(RNG.Next(visibleSets.Count)); + case RandomSelectAlgorithm.Random: + set = visibleSets.ElementAt(RNG.Next(visibleSets.Count)); + break; - if (CurrentSelectionItem != null) - playSpinSample(distanceBetween(carouselItems.First(i => !ReferenceEquals(i.Model, set)), CurrentSelectionItem), visibleSets.Count); + default: + throw new ArgumentOutOfRangeException(); + } selectRecommendedDifficultyForBeatmapSet(set); return true; @@ -696,10 +766,10 @@ namespace osu.Game.Screens.SelectV2 if (carouselItems?.Any() != true) return; - while (randomSelectedBeatmaps.Any()) + while (randomHistory.Any()) { - var previousBeatmap = randomSelectedBeatmaps[^1]; - randomSelectedBeatmaps.RemoveAt(randomSelectedBeatmaps.Count - 1); + var previousBeatmap = randomHistory[^1]; + randomHistory.RemoveAt(randomHistory.Count - 1); var previousBeatmapItem = carouselItems.FirstOrDefault(i => i.Model is BeatmapInfo b && b.Equals(previousBeatmap)); @@ -709,7 +779,7 @@ namespace osu.Game.Screens.SelectV2 if (CurrentSelection is BeatmapInfo beatmapInfo) { if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) - previouslyVisitedRandomSets.Remove(beatmapInfo.BeatmapSet!); + previouslyVisitedRandomBeatmaps.Remove(beatmapInfo); if (CurrentSelectionItem == null) playSpinSample(0, carouselItems.Count); From 873d62291899cfb55605995209c10117b1365b44 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 16:57:32 +0900 Subject: [PATCH 057/173] Fix global beatmap validity potentially being checked on a stale beatmap Noticed in a flaky test with the changes to random, where the debounce may be delayed to the point of thinking the selection is invalid even though it's valid (timing woes, I cannot explain in words but I would highly recommend smiling and nodding approach). --- osu.Game/Screens/SelectV2/SongSelect.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index fc15090a5b..58de111324 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -467,6 +467,9 @@ namespace osu.Game.Screens.SelectV2 if (!this.IsCurrentScreen()) return false; + if (selectionDebounce?.State == ScheduledDelegate.RunState.Waiting) + selectionDebounce?.RunTask(); + // While filtering, let's not ever attempt to change selection. // This will be resolved after the filter completes, see `newItemsPresented`. bool carouselStateIsValid = filterDebounce?.State != ScheduledDelegate.RunState.Waiting && !carousel.IsFiltering; From 10132f19aadc882f6aa542596ec0ea303df5cc9a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 17:49:06 +0900 Subject: [PATCH 058/173] Refactor group deselection logic to avoid adding complexity to `traverseSelection` --- osu.Game/Graphics/Carousel/Carousel.cs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 545fac0e98..06751dee80 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -484,7 +484,13 @@ namespace osu.Game.Graphics.Carousel return true; case GlobalAction.ToggleCurrentGroup: - if (currentKeyboardSelection.CarouselItem != null && CheckValidForGroupSelection(currentKeyboardSelection.CarouselItem)) + if (carouselItems == null || carouselItems.Count == 0) + return true; + + if (currentKeyboardSelection.CarouselItem == null || currentKeyboardSelection.Index == null) + return true; + + if (CheckValidForGroupSelection(currentKeyboardSelection.CarouselItem)) { // If keyboard selection is a group, toggle group and then change keyboard selection to actual selection. Activate(currentKeyboardSelection.CarouselItem); @@ -492,7 +498,16 @@ namespace osu.Game.Graphics.Carousel else { // If current keyboard selection is not a group, toggle the closest group and move keyboard selection to that group. - traverseSelection(-1, CheckValidForGroupSelection, skipFirst: false, activateExpandedItems: true); + for (int i = currentKeyboardSelection.Index.Value; i >= 0; i--) + { + var newItem = carouselItems[i]; + + if (CheckValidForGroupSelection(newItem)) + { + Activate(newItem); + return true; + } + } } return true; @@ -577,7 +592,7 @@ namespace osu.Game.Graphics.Carousel traverseSelection(direction, CheckValidForSetSelection); } - private void traverseSelection(int direction, Func predicate, bool skipFirst = true, bool activateExpandedItems = false) + private void traverseSelection(int direction, Func predicate, bool skipFirst = true) { if (carouselItems == null || carouselItems.Count == 0) return; @@ -616,7 +631,7 @@ namespace osu.Game.Graphics.Carousel var newItem = carouselItems[newIndex]; - if ((activateExpandedItems || !newItem.IsExpanded) && predicate(newItem)) + if (!newItem.IsExpanded && predicate(newItem)) { Activate(newItem); return; From 6350e6d1a7b851054c40d91b1ab30087709917ef Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 18:30:54 +0900 Subject: [PATCH 059/173] SongSelectV2: Fix pressing multiple traversal keys in same frame causing weirdness Closes #33453. --- .../TestSceneBeatmapCarouselNoGrouping.cs | 32 +++++++++++++++++++ osu.Game/Graphics/Carousel/Carousel.cs | 29 ++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs index a6ba6d76a3..c5f7db022a 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs @@ -155,6 +155,38 @@ namespace osu.Game.Tests.Visual.SongSelectV2 WaitForSetSelection(1, 0); } + [Test] + public void TestMultipleKeyboardOperationsPerFrame() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + SelectNextSet(); + WaitForSetSelection(0, 0); + + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + + AddStep("Press two keys at once", () => + { + InputManager.Key(Key.Down); + InputManager.Key(Key.Right); + }); + + // Second key is respected, so only set selection changes. + WaitForSetSelection(1, 0); + + AddStep("Press two keys at once", () => + { + InputManager.Key(Key.Left); + InputManager.Key(Key.Up); + }); + + // Second key is respected, so only keyboard selection changes. + WaitForSetSelection(1, 0); + } + [Test] public void TestKeyboardSelection() { diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index ab3e860f8b..a70f5b053a 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -453,25 +453,46 @@ namespace osu.Game.Graphics.Carousel // `refreshAfterSelection()` is the method responsible for updating the index of the selected item here which runs once per frame. case GlobalAction.SelectNext: - Scheduler.AddOnce(traverseKeyboardSelection, 1); + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, 1)); return true; case GlobalAction.SelectPrevious: - Scheduler.AddOnce(traverseKeyboardSelection, -1); + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, -1)); return true; case GlobalAction.ActivateNextSet: - Scheduler.AddOnce(traverseSetSelection, 1); + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, 1)); return true; case GlobalAction.ActivatePreviousSet: - Scheduler.AddOnce(traverseSetSelection, -1); + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, -1)); return true; } return false; + + void traverseFromKey(TraversalOperation traversal) + { + switch (traversal.Type) + { + case TraversalType.Keyboard: + traverseKeyboardSelection(traversal.Direction); + break; + + case TraversalType.Set: + traverseSetSelection(traversal.Direction); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } } + private enum TraversalType { Keyboard, Set } + + private record TraversalOperation(TraversalType Type, int Direction); + public void OnReleased(KeyBindingReleaseEvent e) { } From cb90dee3e82144e02008d3a6b3ef68833c1862cf Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 18:44:22 +0900 Subject: [PATCH 060/173] Remove noisy carousel logging Served its purpose, no longer required. --- osu.Game/Graphics/Carousel/Carousel.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index ab3e860f8b..073566b886 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -732,9 +732,7 @@ namespace osu.Game.Graphics.Carousel if (range != displayedRange) { - Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}"); displayedRange = range; - updateDisplayedRange(range); } From ef5638b6b35b41bffccf1dba09e2d518fdb24528 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 19:08:18 +0900 Subject: [PATCH 061/173] Fix in song select v1 too for good measure --- osu.Game/Screens/Select/PlaySongSelect.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index c49b7c2ef2..2f47243b50 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -92,7 +92,7 @@ namespace osu.Game.Screens.Select { if (playerLoader != null) return false; - modsAtGameplayStart = Mods.Value; + modsAtGameplayStart = Mods.Value.Select(m => m.DeepClone()).ToArray(); // Ctrl+Enter should start map with autoplay enabled. if (GetContainingInputManager()?.CurrentState?.Keyboard.ControlPressed == true) From 088659ae04183143c28e8aebd680ceb583555eef Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 19:16:12 +0900 Subject: [PATCH 062/173] Adjust test timings to avoid flaky test failing The gameplay clock runs at 1000 ms intervals, and the previous duration meant that the "store" step could cause a missed spinning check in a bad case scenario. --- osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs index f6e460284b..fd947343e4 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs @@ -110,23 +110,23 @@ namespace osu.Game.Rulesets.Osu.Tests new Spinner { StartTime = 0, - Duration = 1000, + Duration = 3000, Position = OsuPlayfield.BASE_SIZE / 2, }, new Slider { - StartTime = 2500, + StartTime = 4500, RepeatCount = 0, Position = OsuPlayfield.BASE_SIZE / 2, Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), - new PathControlPoint(new Vector2(100, 0)), + new PathControlPoint(new Vector2(200, 0)), }) }, new HitCircle { - StartTime = 4500, + StartTime = 10000, Position = OsuPlayfield.BASE_SIZE / 2, }, }, From 9a602345525f275aa1f58b1396d9d7033e4e55db Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 19:27:14 +0900 Subject: [PATCH 063/173] Fix flaky test failures at main menu due to early `ScalingContainer` access See https://github.com/ppy/osu/actions/runs/15754567890/job/44406960692?pr=33775. Logic was running every frame incorrectly. Including transforms doing many allocations. --- osu.Game/Screens/Menu/MainMenu.cs | 71 +++++++++++++++++-------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 06f62542f8..d87727b797 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -519,16 +519,45 @@ namespace osu.Game.Screens.Menu private void updateSongSelectV2HoldState() { - if (Buttons.State == ButtonSystemState.Play && - inputManager.CurrentState.Mouse.IsPressed(MouseButton.Left) && - inputManager.HoveredDrawables.Any(h => h is OsuLogo || (h is MainMenuButton b && b.TriggerKeys.Contains(Key.P)))) - holdTime += Time.Elapsed; - else + bool isValidHoverState = Buttons.State == ButtonSystemState.Play && + inputManager.CurrentState.Mouse.IsPressed(MouseButton.Left) && + inputManager.HoveredDrawables.Any(h => h is OsuLogo || (h is MainMenuButton b && b.TriggerKeys.Contains(Key.P))); + + if (isValidHoverState) { - var transformTarget = Game.ChildrenOfType().First(); - transformTarget.ScaleTo(1, 200, Easing.OutQuint) - .RotateTo(0, 200, Easing.OutQuint) - .FadeColour(OsuColour.Gray(1f), 200, Easing.OutQuint); + holdTime += Time.Elapsed; + + if (holdTime >= required_hold_time && !ssv2Expanded) + { + var transformTarget = Game.ChildrenOfType().First(); + + transformTarget.Anchor = Anchor.Centre; + transformTarget.Origin = Anchor.Centre; + + transformTarget.ScaleTo(1.2f, 5000, Easing.OutPow10) + .RotateTo(2, 5000, Easing.OutPow10) + .FadeColour(Color4.BlueViolet, 10000, Easing.OutPow10); + + ssv2Duck = musicController.Duck(new DuckParameters + { + DuckDuration = 2000, + DuckVolumeTo = 0.8f, + DuckCutoffTo = 500, + DuckEasing = Easing.OutQuint, + RestoreDuration = 200, + RestoreEasing = Easing.OutQuint + }); + + ssv2Expanded = true; + } + } + else if (holdTime > 0) + { + var transformTarget = Game.ChildrenOfType().FirstOrDefault(); + + transformTarget.ScaleTo(1, 500, Easing.OutQuint) + .RotateTo(0, 500, Easing.OutQuint) + .FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); ssv2Duck?.Dispose(); ssv2Duck = null; @@ -536,30 +565,6 @@ namespace osu.Game.Screens.Menu ssv2Expanded = false; holdTime = 0; } - - if (holdTime >= required_hold_time && !ssv2Expanded) - { - var transformTarget = Game.ChildrenOfType().First(); - - transformTarget.Anchor = Anchor.Centre; - transformTarget.Origin = Anchor.Centre; - - transformTarget.ScaleTo(1.2f, 5000, Easing.OutPow10) - .RotateTo(2, 5000, Easing.OutPow10) - .FadeColour(Color4.BlueViolet, 10000, Easing.OutPow10); - - ssv2Duck = musicController.Duck(new DuckParameters - { - DuckDuration = 2000, - DuckVolumeTo = 0.8f, - DuckCutoffTo = 500, - DuckEasing = Easing.OutQuint, - RestoreDuration = 200, - RestoreEasing = Easing.OutQuint - }); - - ssv2Expanded = true; - } } #endregion From 116463e30d92ed3134522e076f0fc3ad2d9a4da9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 19 Jun 2025 23:52:11 +0900 Subject: [PATCH 064/173] Remove unused second parameter --- osu.Game/Graphics/Carousel/Carousel.cs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 06751dee80..a2ab35a58f 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -592,7 +592,7 @@ namespace osu.Game.Graphics.Carousel traverseSelection(direction, CheckValidForSetSelection); } - private void traverseSelection(int direction, Func predicate, bool skipFirst = true) + private void traverseSelection(int direction, Func predicate) { if (carouselItems == null || carouselItems.Count == 0) return; @@ -608,15 +608,12 @@ namespace osu.Game.Graphics.Carousel { newIndex = originalIndex = currentKeyboardSelection.Index.Value; - if (skipFirst) + // As a second special case, if we're set selecting backwards and the current selection isn't a set, + // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early. + if (direction < 0) { - // As a second special case, if we're set selecting backwards and the current selection isn't a set, - // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early. - if (direction < 0) - { - while (newIndex > 0 && !predicate(carouselItems[newIndex])) - newIndex--; - } + while (newIndex > 0 && !predicate(carouselItems[newIndex])) + newIndex--; } } From a6c7e20ffcc01c44ed82f145831bf6674df10235 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 20 Jun 2025 02:26:46 +0900 Subject: [PATCH 065/173] Add note about reasoning for low battery threshold --- osu.Game/Screens/Play/PlayerLoader.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 94148c13d0..27b6413d0c 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -715,6 +715,10 @@ namespace osu.Game.Screens.Play #region Low battery warning + /// + /// This is intentionally higher than 20%, which is usually when OS level notifications + /// interrupt the active application to warn the user. + /// private const double low_battery_threshold = 0.25; private Bindable batteryWarningShownOnce = null!; From 4177dc395bbb420ffca16f94a7313dc0c454393b Mon Sep 17 00:00:00 2001 From: eyhn Date: Fri, 20 Jun 2025 16:23:18 +0800 Subject: [PATCH 066/173] Fix beatmap set author information --- osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index c6cf0f735f..d04c59c168 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -80,6 +80,11 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("artist_unicode")] public string ArtistUnicode { get; set; } = string.Empty; + /// + /// In the beatmap search API, this property is not provided. + /// In such cases, the following two properties will be used to provide the Author information. + /// + [JsonProperty(@"user")] public APIUser Author = new APIUser(); /// From 131b3f622e55f71b3992bd1c71a01aad33c7880d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 20 Jun 2025 18:08:25 +0900 Subject: [PATCH 067/173] Update group traversal logic to use new debouncing flow --- osu.Game/Graphics/Carousel/Carousel.cs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index cb7dcadf44..66e8aaf008 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -458,29 +458,28 @@ namespace osu.Game.Graphics.Carousel // if the selection is changed more than once during an update frame, // which can happen if repeat inputs are enqueued for processing at a rate faster than the update refresh rate. // `refreshAfterSelection()` is the method responsible for updating the index of the selected item here which runs once per frame. - - case GlobalAction.SelectNext: - Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, 1)); - return true; - case GlobalAction.SelectPrevious: Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, -1)); return true; - case GlobalAction.ActivateNextSet: - Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, 1)); + case GlobalAction.SelectNext: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, 1)); return true; case GlobalAction.ActivatePreviousSet: Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, -1)); return true; + case GlobalAction.ActivateNextSet: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, 1)); + return true; + case GlobalAction.ExpandPreviousGroup: - Scheduler.AddOnce(traverseGroupSelection, -1); + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Group, -1)); return true; case GlobalAction.ExpandNextGroup: - Scheduler.AddOnce(traverseGroupSelection, 1); + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Group, 1)); return true; case GlobalAction.ToggleCurrentGroup: @@ -527,13 +526,17 @@ namespace osu.Game.Graphics.Carousel traverseSetSelection(traversal.Direction); break; + case TraversalType.Group: + traverseGroupSelection(traversal.Direction); + break; + default: throw new ArgumentOutOfRangeException(); } } } - private enum TraversalType { Keyboard, Set } + private enum TraversalType { Keyboard, Set, Group } private record TraversalOperation(TraversalType Type, int Direction); From f3f137dbd43bb30e9fed40706efa406321e61ece Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 12 Jun 2025 17:01:55 +0900 Subject: [PATCH 068/173] Fix incorrectly named variable --- osu.Game/Screens/Footer/ScreenFooter.cs | 4 ++-- osu.Game/Screens/Footer/ScreenFooterButton.cs | 6 +++--- osu.Game/Screens/SelectV2/FooterButtonMods.cs | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index 7fd5e8537a..be9411c858 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -100,7 +100,7 @@ namespace osu.Game.Screens.Footer { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Y = ScreenFooterButton.Y_OFFSET, + Y = ScreenFooterButton.CORNER_RADIUS, Direction = FillDirection.Horizontal, Spacing = new Vector2(7, 0), AutoSizeAxes = Axes.Both, @@ -123,7 +123,7 @@ namespace osu.Game.Screens.Footer hiddenButtonsContainer = new Container { Margin = new MarginPadding { Left = OsuGame.SCREEN_EDGE_MARGIN + ScreenBackButton.BUTTON_WIDTH + padding }, - Y = ScreenFooterButton.Y_OFFSET, + Y = ScreenFooterButton.CORNER_RADIUS, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, AutoSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/Footer/ScreenFooterButton.cs b/osu.Game/Screens/Footer/ScreenFooterButton.cs index 2b23560c26..e877c91d11 100644 --- a/osu.Game/Screens/Footer/ScreenFooterButton.cs +++ b/osu.Game/Screens/Footer/ScreenFooterButton.cs @@ -25,7 +25,7 @@ namespace osu.Game.Screens.Footer { public partial class ScreenFooterButton : OsuClickableContainer, IKeyBindingHandler { - public const int Y_OFFSET = 10; + public const int CORNER_RADIUS = 10; protected const int BUTTON_HEIGHT = 75; protected const int BUTTON_WIDTH = 116; @@ -91,7 +91,7 @@ namespace osu.Game.Screens.Footer }, Shear = OsuGame.SHEAR, Masking = true, - CornerRadius = 10, + CornerRadius = CORNER_RADIUS, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -138,7 +138,7 @@ namespace osu.Game.Screens.Footer Shear = -OsuGame.SHEAR, Anchor = Anchor.BottomCentre, Origin = Anchor.Centre, - Y = -Y_OFFSET, + Y = -CORNER_RADIUS, Size = new Vector2(100, 5), Masking = true, CornerRadius = 3, diff --git a/osu.Game/Screens/SelectV2/FooterButtonMods.cs b/osu.Game/Screens/SelectV2/FooterButtonMods.cs index 8ea08a0085..9de06988a5 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonMods.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonMods.cs @@ -86,7 +86,7 @@ namespace osu.Game.Screens.SelectV2 Depth = float.MaxValue, Origin = Anchor.BottomLeft, Shear = OsuGame.SHEAR, - CornerRadius = Y_OFFSET, + CornerRadius = CORNER_RADIUS, Size = new Vector2(BUTTON_WIDTH, bar_height), Masking = true, EdgeEffect = new EdgeEffectParameters @@ -122,7 +122,7 @@ namespace osu.Game.Screens.SelectV2 }, modContainer = new Container { - CornerRadius = Y_OFFSET, + CornerRadius = CORNER_RADIUS, RelativeSizeAxes = Axes.Both, Width = mod_display_portion, Masking = true, @@ -304,7 +304,7 @@ namespace osu.Game.Screens.SelectV2 private void load() { AutoSizeAxes = Axes.Both; - CornerRadius = Y_OFFSET; + CornerRadius = CORNER_RADIUS; Masking = true; InternalChildren = new Drawable[] @@ -346,7 +346,7 @@ namespace osu.Game.Screens.SelectV2 Depth = float.MaxValue; Origin = Anchor.BottomLeft; Shear = OsuGame.SHEAR; - CornerRadius = Y_OFFSET; + CornerRadius = CORNER_RADIUS; AutoSizeAxes = Axes.X; Height = bar_height; Masking = true; From 30fb0c530f000029d5f10a5e3fa9ed2fec04df0d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 20 Jun 2025 20:29:35 +0900 Subject: [PATCH 069/173] Remove fade from footer display transition Fades should not be used for these kinds of elements. The opacity changes of multiple elements looks shocking. It's unnecessary. --- osu.Game/Screens/Footer/ScreenFooter.cs | 13 +++++++++---- osu.Game/Screens/Footer/ScreenFooterButton.cs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index be9411c858..ad3aaaa2c9 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -40,7 +40,11 @@ namespace osu.Game.Screens.Footer private const int padding = 60; private const float delay_per_button = 30; - private const double transition_duration = 400; + private const double transition_duration = 500; + + // Disable masking because it breaks due to the height of this container being less than the displayed content. + // The height being set as it is is required for transition purposes. + public override bool UpdateSubTreeMasking() => false; private readonly List overlays = new List(); @@ -162,13 +166,14 @@ namespace osu.Game.Screens.Footer protected override void PopIn() { this.MoveToY(0, transition_duration, Easing.OutQuint) - .FadeIn(transition_duration, Easing.OutQuint); + .FadeIn(); } protected override void PopOut() { - this.MoveToY(HEIGHT, transition_duration, Easing.OutQuint) - .FadeOut(transition_duration, Easing.OutQuint); + this.MoveToY(ScreenFooterButton.HEIGHT, transition_duration, Easing.OutQuint) + .Then() + .FadeOut(); } public void SetButtons(IReadOnlyList buttons) diff --git a/osu.Game/Screens/Footer/ScreenFooterButton.cs b/osu.Game/Screens/Footer/ScreenFooterButton.cs index e877c91d11..5d064670e7 100644 --- a/osu.Game/Screens/Footer/ScreenFooterButton.cs +++ b/osu.Game/Screens/Footer/ScreenFooterButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Footer { public const int CORNER_RADIUS = 10; - protected const int BUTTON_HEIGHT = 75; + public const int HEIGHT = 75; protected const int BUTTON_WIDTH = 116; public Bindable OverlayState = new Bindable(); @@ -75,7 +75,7 @@ namespace osu.Game.Screens.Footer { Overlay = overlay; - Size = new Vector2(BUTTON_WIDTH, BUTTON_HEIGHT); + Size = new Vector2(BUTTON_WIDTH, HEIGHT); Children = new Drawable[] { From 3af0ce7e5e020e3d32682a2484487126b9c7ac1a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 20 Jun 2025 20:19:39 +0900 Subject: [PATCH 070/173] Fix tests --- .../TestSceneMultiplayerMatchSubScreen.cs | 2 +- .../SongSelectV2/TestSceneScreenFooter.cs | 24 +++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 920a920b9b..e0a0e5a785 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -308,7 +308,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => RoomJoined); ClickButtonWhenEnabled(); - AddAssert("mod select shows unranked", () => this.ChildrenOfType().Single().Ranked.Value == false); + AddUntilStep("mod select shows unranked", () => this.ChildrenOfType().Single().Ranked.Value == false); AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); AddStep("select flashlight", () => this.ChildrenOfType().Single().ChildrenOfType().Single(m => m.Mod is ModFlashlight).TriggerClick()); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs index bdecebd64f..e247b92f52 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs @@ -114,11 +114,11 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddWaitStep("wait for transition", 3); AddStep("show overlay", () => externalOverlay.Show()); - AddAssert("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + contentDisplayed(); AddUntilStep("other buttons hidden", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); AddStep("hide overlay", () => externalOverlay.Hide()); - AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + contentHidden(); AddUntilStep("other buttons returned", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); } @@ -133,11 +133,11 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); AddStep("show external overlay", () => externalOverlay.Show()); AddAssert("footer shown", () => screenFooter.State.Value == Visibility.Visible); - AddAssert("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + contentDisplayed(); AddStep("hide external overlay", () => externalOverlay.Hide()); AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); - AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + contentHidden(); AddStep("show footer", () => screenFooter.Show()); AddAssert("content still hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); @@ -216,17 +216,27 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddWaitStep("wait for transition", 3); AddStep("show overlay", () => externalOverlay.Show()); - AddAssert("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + contentDisplayed(); AddUntilStep("other buttons hidden", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); AddStep("resize active button", () => this.ChildrenOfType().First().ResizeWidthTo(240, 300, Easing.OutQuint)); AddStep("resize active button back", () => this.ChildrenOfType().First().ResizeWidthTo(116, 300, Easing.OutQuint)); AddStep("hide overlay", () => externalOverlay.Hide()); - AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + contentHidden(); AddUntilStep("other buttons returned", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); } + private void contentHidden() + { + AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + } + + private void contentDisplayed() + { + AddUntilStep("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + } + private partial class TestShearedOverlayContainer : ShearedOverlayContainer { public TestShearedOverlayContainer() @@ -261,7 +271,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.Both; + AutoSizeAxes = Axes.Both; InternalChild = new FillFlowContainer { From fd27d43814fc694cb62fa3f2b92b8bd3d95c926b Mon Sep 17 00:00:00 2001 From: diquoks Date: Fri, 20 Jun 2025 18:40:21 +0300 Subject: [PATCH 071/173] Use localised strings for SSV2 --- .../BeatmapLeaderboardScore_Tooltip.cs | 3 ++- .../Screens/SelectV2/BeatmapMetadataWedge.cs | 19 +++++++++++-------- .../BeatmapTitleWedge_DifficultyDisplay.cs | 2 +- .../FilterControl_DifficultyRangeSlider.cs | 3 ++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs index 178fb1df00..bc684dfc13 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs @@ -22,6 +22,7 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; @@ -126,7 +127,7 @@ namespace osu.Game.Screens.SelectV2 var generalStatistics = new[] { new PerformanceStatisticRow(BeatmapsetsStrings.ShowScoreboardHeaderspp.ToUpper(), colourProvider.Content2, score), - new StatisticRow("Score Multiplier", colourProvider.Content2, ModUtils.FormatScoreMultiplier(multiplier)), + new StatisticRow(ModSelectOverlayStrings.ScoreMultiplier, colourProvider.Content2, ModUtils.FormatScoreMultiplier(multiplier)), new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeadersCombo, colourProvider.Content2, value.MaxCombo.ToLocalisableString(@"0\x")), new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, colourProvider.Content2, value.Accuracy.FormatAccuracy()), }; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 5a0222ec20..d5da1d8c25 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -5,15 +5,18 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; +using osu.Game.Localisation; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Screens.SelectV2 @@ -124,8 +127,8 @@ namespace osu.Game.Screens.SelectV2 Spacing = new Vector2(0f, 10f), Children = new[] { - creator = new MetadataDisplay("Creator"), - genre = new MetadataDisplay("Genre"), + creator = new MetadataDisplay(EditorSetupStrings.Creator), + genre = new MetadataDisplay(BeatmapsetsStrings.ShowInfoGenre), }, }, new FillFlowContainer @@ -136,8 +139,8 @@ namespace osu.Game.Screens.SelectV2 Spacing = new Vector2(0f, 10f), Children = new[] { - source = new MetadataDisplay("Source"), - language = new MetadataDisplay("Language"), + source = new MetadataDisplay(BeatmapsetsStrings.ShowInfoSource), + language = new MetadataDisplay(BeatmapsetsStrings.ShowInfoLanguage), }, }, new FillFlowContainer @@ -148,18 +151,18 @@ namespace osu.Game.Screens.SelectV2 Spacing = new Vector2(0f, 10f), Children = new[] { - submitted = new MetadataDisplay("Submitted"), - ranked = new MetadataDisplay("Ranked"), + submitted = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateSubmitted("").ToSentence()), + ranked = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateRanked("").ToSentence()), }, }, }, }, }, - userTags = new MetadataDisplay("User Tags") + userTags = new MetadataDisplay(BeatmapsetsStrings.ShowInfoUserTags) { Alpha = 0, }, - mapperTags = new MetadataDisplay("Mapper Tags"), + mapperTags = new MetadataDisplay(BeatmapsetsStrings.ShowInfoMapperTags), }, }, }, diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index a4be87953c..7595afdbd7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -136,7 +136,7 @@ namespace osu.Game.Screens.SelectV2 { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = " mapped by ", + Text = BeatmapsStrings.DiscussionsShowTitle("", ""), Font = OsuFont.Style.Body, }, mapperLink = new MapperLinkContainer diff --git a/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs b/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs index 52ff41fe63..f65c17bddf 100644 --- a/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs +++ b/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs @@ -16,6 +16,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osu.Game.Utils; using osuTK.Graphics; @@ -34,7 +35,7 @@ namespace osu.Game.Screens.SelectV2 .Prepend((0.0f, OsuColour.STAR_DIFFICULTY_SPECTRUM.ElementAt(1).Item2)).ToArray(); public DifficultyRangeSlider() - : base("Star Rating") + : base(BeatmapsetsStrings.ShowStatsStars) { NubWidth = ShearedNub.HEIGHT * 1.16f; DefaultStringUpperBound = "∞"; From b603a88043858f2fe1b627534ba309a8be7e4b95 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 20 Jun 2025 20:21:44 +0300 Subject: [PATCH 072/173] Reword documentation --- .../API/Requests/Responses/APIBeatmapSet.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index d04c59c168..e8e08059b9 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -81,15 +81,21 @@ namespace osu.Game.Online.API.Requests.Responses public string ArtistUnicode { get; set; } = string.Empty; /// - /// In the beatmap search API, this property is not provided. - /// In such cases, the following two properties will be used to provide the Author information. + /// The creator of this beatmap set. /// + /// + /// This is not included when the set is retrieved via , + /// but the creator's ID and username will be filled in this property from the and properties. + /// [JsonProperty(@"user")] public APIUser Author = new APIUser(); /// - /// Helper property to deserialize a username to . + /// The ID of the beatmap set's creator. /// + /// + /// Helper property to deserialize the ID to . + /// [JsonProperty(@"user_id")] public int AuthorID { @@ -98,8 +104,11 @@ namespace osu.Game.Online.API.Requests.Responses } /// - /// Helper property to deserialize a username to . + /// The username of the beatmap set's creator. /// + /// + /// Helper property to deserialize the username to . + /// [JsonProperty(@"creator")] public string AuthorString { From acc4267a2d5ebf942ac0cdf0f1a477fc993e14c0 Mon Sep 17 00:00:00 2001 From: diquoks Date: Sat, 21 Jun 2025 12:35:02 +0300 Subject: [PATCH 073/173] Use `MarginPadding` instead of leading space, change incorrect `LocalisableString` --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 4 ++-- .../Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index d5da1d8c25..d7bc73193d 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -151,8 +151,8 @@ namespace osu.Game.Screens.SelectV2 Spacing = new Vector2(0f, 10f), Children = new[] { - submitted = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateSubmitted("").ToSentence()), - ranked = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateRanked("").ToSentence()), + submitted = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateSubmitted(string.Empty).ToSentence()), + ranked = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateRanked(string.Empty).ToSentence()), }, }, }, diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index 7595afdbd7..4af5e5846c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -130,13 +130,14 @@ namespace osu.Game.Screens.SelectV2 { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, + Padding = new MarginPadding { Right = 3f }, Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), }, mappedByText = new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = BeatmapsStrings.DiscussionsShowTitle("", ""), + Text = BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty), Font = OsuFont.Style.Body, }, mapperLink = new MapperLinkContainer From 3192eaa2a20f20495db8431d3d6f35af7c705a94 Mon Sep 17 00:00:00 2001 From: diquoks Date: Sat, 21 Jun 2025 14:05:20 +0300 Subject: [PATCH 074/173] Add localisation to `Collections` string on `SongSelect` --- osu.Game/Screens/SelectV2/PanelBeatmapSet.cs | 2 +- osu.Game/Screens/SelectV2/SongSelect.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs index 425ca02e5a..9bdd188a3a 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -232,7 +232,7 @@ namespace osu.Game.Screens.SelectV2 if (manageCollectionsDialog != null) collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + items.Add(new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems }); if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => songSelect?.RestoreAllHidden(beatmapSet))); diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index fc15090a5b..d0b697abc9 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -904,7 +904,7 @@ namespace osu.Game.Screens.SelectV2 collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, () => manageCollectionsDialog?.Show())); - yield return new OsuMenuItem("Collections") { Items = collectionItems }; + yield return new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems }; } public void ManageCollections() => collectionsDialog?.Show(); From 4661cb48188718310abd68ae558b346463df1226 Mon Sep 17 00:00:00 2001 From: diquoks Date: Sat, 21 Jun 2025 18:23:50 +0300 Subject: [PATCH 075/173] Add new strings to `SongSelectStrings.cs` --- osu.Game/Localisation/SongSelectStrings.cs | 10 ++++++++++ osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 5 ++--- .../SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/osu.Game/Localisation/SongSelectStrings.cs b/osu.Game/Localisation/SongSelectStrings.cs index 6b4527f063..0a031332dd 100644 --- a/osu.Game/Localisation/SongSelectStrings.cs +++ b/osu.Game/Localisation/SongSelectStrings.cs @@ -59,6 +59,16 @@ namespace osu.Game.Localisation /// public static LocalisableString Stars(LocalisableString value) => new TranslatableString(getKey(@"stars"), @"{0} stars", value); + /// + /// "Submitted" + /// + public static LocalisableString Submitted => new TranslatableString(getKey(@"submitted"), @"Submitted"); + + /// + /// "Ranked" + /// + public static LocalisableString Ranked => new TranslatableString(getKey(@"ranked"), @"Ranked"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index d7bc73193d..b3d3bb6279 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; @@ -151,8 +150,8 @@ namespace osu.Game.Screens.SelectV2 Spacing = new Vector2(0f, 10f), Children = new[] { - submitted = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateSubmitted(string.Empty).ToSentence()), - ranked = new MetadataDisplay(BeatmapsetsStrings.ShowDetailsDateRanked(string.Empty).ToSentence()), + submitted = new MetadataDisplay(SongSelectStrings.Submitted), + ranked = new MetadataDisplay(SongSelectStrings.Ranked), }, }, }, diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index 4af5e5846c..cdf012479e 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -130,6 +130,7 @@ namespace osu.Game.Screens.SelectV2 { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, + // margin to replicate the missing leading space in `ShowDetailsMappedBy` string Padding = new MarginPadding { Right = 3f }, Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), }, From cad43823509cda3e45d2f99e86deff5f4cd0695f Mon Sep 17 00:00:00 2001 From: diquoks Date: Sat, 21 Jun 2025 21:01:48 +0300 Subject: [PATCH 076/173] Add localisation usage to `BeatmapStatistic` and revert `ShowDetailsMappedBy` --- .../Beatmaps/CatchBeatmap.cs | 7 +- .../Beatmaps/ManiaBeatmap.cs | 5 +- osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs | 7 +- .../Beatmaps/TaikoBeatmap.cs | 7 +- .../Localisation/BeatmapStatisticStrings.cs | 69 +++++++++++++++++++ .../BeatmapTitleWedge_DifficultyDisplay.cs | 2 +- 6 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 osu.Game/Localisation/BeatmapStatisticStrings.cs diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs index d43290e661..1ff5083aaf 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Objects; @@ -23,21 +24,21 @@ namespace osu.Game.Rulesets.Catch.Beatmaps { new BeatmapStatistic { - Name = @"Fruits", + Name = BeatmapStatisticStrings.Fruits, Content = fruits.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), BarDisplayLength = fruits / (float)sum, }, new BeatmapStatistic { - Name = @"Juice Streams", + Name = BeatmapStatisticStrings.JuiceStreams, Content = juiceStreams.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), BarDisplayLength = juiceStreams / (float)sum, }, new BeatmapStatistic { - Name = @"Banana Showers", + Name = BeatmapStatisticStrings.BananaShowers, Content = bananaShowers.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), BarDisplayLength = Math.Min(bananaShowers / 10f, 1), diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index 3ee1b63800..1f8380a9f7 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; @@ -42,14 +43,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps { new BeatmapStatistic { - Name = @"Notes", + Name = BeatmapStatisticStrings.Notes, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), Content = notes.ToString(), BarDisplayLength = notes / (float)sum, }, new BeatmapStatistic { - Name = @"Hold Notes", + Name = BeatmapStatisticStrings.HoldNotes, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), Content = holdNotes.ToString(), BarDisplayLength = holdNotes / (float)sum, diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs index d11b4aac3b..87e592a41c 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Beatmaps @@ -22,21 +23,21 @@ namespace osu.Game.Rulesets.Osu.Beatmaps { new BeatmapStatistic { - Name = "Circles", + Name = BeatmapStatisticStrings.Circles, Content = circles.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), BarDisplayLength = circles / (float)sum, }, new BeatmapStatistic { - Name = "Sliders", + Name = BeatmapStatisticStrings.Sliders, Content = sliders.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), BarDisplayLength = sliders / (float)sum, }, new BeatmapStatistic { - Name = @"Spinners", + Name = BeatmapStatisticStrings.Spinners, Content = spinners.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), BarDisplayLength = Math.Min(spinners / 10f, 1), diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs index 5b0582ab59..4a38381bbe 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Beatmaps @@ -22,21 +23,21 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps { new BeatmapStatistic { - Name = @"Hits", + Name = BeatmapStatisticStrings.Hits, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), Content = hits.ToString(), BarDisplayLength = hits / (float)sum, }, new BeatmapStatistic { - Name = @"Drumrolls", + Name = BeatmapStatisticStrings.Drumrolls, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), Content = drumRolls.ToString(), BarDisplayLength = drumRolls / (float)sum, }, new BeatmapStatistic { - Name = @"Swells", + Name = BeatmapStatisticStrings.Swells, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), Content = swells.ToString(), BarDisplayLength = Math.Min(swells / 10f, 1), diff --git a/osu.Game/Localisation/BeatmapStatisticStrings.cs b/osu.Game/Localisation/BeatmapStatisticStrings.cs new file mode 100644 index 0000000000..47cc153ac7 --- /dev/null +++ b/osu.Game/Localisation/BeatmapStatisticStrings.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public class BeatmapStatisticStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapStatisticStrings"; + + /// + /// "Circles" + /// + public static LocalisableString Circles => new TranslatableString(getKey(@"circles"), @"Circles"); + + /// + /// "Sliders" + /// + public static LocalisableString Sliders => new TranslatableString(getKey(@"sliders"), @"Sliders"); + + /// + /// "Spinners" + /// + public static LocalisableString Spinners => new TranslatableString(getKey(@"spinners"), @"Spinners"); + + /// + /// "Hits" + /// + public static LocalisableString Hits => new TranslatableString(getKey(@"hits"), @"Hits"); + + /// + /// "Drumrolls" + /// + public static LocalisableString Drumrolls => new TranslatableString(getKey(@"drumrolls"), @"Drumrolls"); + + /// + /// "Swells" + /// + public static LocalisableString Swells => new TranslatableString(getKey(@"swells"), @"Swells"); + + /// + /// "Fruits" + /// + public static LocalisableString Fruits => new TranslatableString(getKey(@"fruits"), @"Fruits"); + + /// + /// "Juice Streams" + /// + public static LocalisableString JuiceStreams => new TranslatableString(getKey(@"juice_streams"), @"Juice Streams"); + + /// + /// "Banana Showers" + /// + public static LocalisableString BananaShowers => new TranslatableString(getKey(@"banana_showers"), @"Banana Showers"); + + /// + /// "Notes" + /// + public static LocalisableString Notes => new TranslatableString(getKey(@"notes"), @"Notes"); + + /// + /// "Hold Notes" + /// + public static LocalisableString HoldNotes => new TranslatableString(getKey(@"hold_notes"), @"Hold Notes"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index cdf012479e..bd3042dc9c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -138,7 +138,7 @@ namespace osu.Game.Screens.SelectV2 { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty), + Text = " mapped by ", Font = OsuFont.Style.Body, }, mapperLink = new MapperLinkContainer From 71e4f4129ffbd0130c460d818aef78e63707ab1b Mon Sep 17 00:00:00 2001 From: diquoks Date: Sat, 21 Jun 2025 21:06:20 +0300 Subject: [PATCH 077/173] Remove remaining excess `MarginPadding` --- .../Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index bd3042dc9c..a4be87953c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -130,8 +130,6 @@ namespace osu.Game.Screens.SelectV2 { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - // margin to replicate the missing leading space in `ShowDetailsMappedBy` string - Padding = new MarginPadding { Right = 3f }, Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), }, mappedByText = new OsuSpriteText From 9b7c722d975624941be8c48d08dbe246dec5b1d1 Mon Sep 17 00:00:00 2001 From: StanR Date: Sun, 22 Jun 2025 01:24:58 +0500 Subject: [PATCH 078/173] Add beatmapset `This beatmap contains video` badge --- .../Online/TestSceneBeatmapSetOverlay.cs | 11 ++++ .../BeatmapSet/BeatmapSetHasVideoBadge.cs | 54 +++++++++++++++++++ .../BeatmapSet/BeatmapSetHeaderContent.cs | 33 ++++++++++-- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 5dc6f950a5..4cb9ec4e88 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -243,6 +243,17 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestBeatmapSetHasVideo() + { + AddStep("show beatmapset with video", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.HasVideo = true; + overlay.ShowBeatmapSet(beatmapSet); + }); + } + [Test] public void TestSelectedModsDontAffectStatistics() { diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs new file mode 100644 index 0000000000..7281b3970c --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class BeatmapSetHasVideoBadge : CircularContainer, IHasTooltip + { + public LocalisableString TooltipText => BeatmapsetsStrings.ShowInfoVideo; + + private readonly Box background; + + public BeatmapSetHasVideoBadge() + { + AutoSizeAxes = Axes.Both; + Masking = true; + Alpha = 0; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Film, + Size = new Vector2(14), + Margin = new MarginPadding(10) + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background6; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index 9b10f6156d..7a48a02f58 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -46,6 +46,7 @@ namespace osu.Game.Overlays.BeatmapSet private readonly Box coverGradient; private readonly LinkFlowContainer title, artist; private readonly AuthorInfo author; + private readonly BeatmapSetHasVideoBadge beatmapSetVideoBadge; private ExternalLinkButton externalLink; @@ -175,13 +176,29 @@ namespace osu.Game.Overlays.BeatmapSet Spacing = new Vector2(10), Children = new Drawable[] { - onlineStatusPill = new BeatmapSetOnlineStatusPill + new FillFlowContainer { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - TextSize = 14, - TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = new Drawable[] + { + onlineStatusPill = new BeatmapSetOnlineStatusPill + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + TextSize = 14, + TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } + }, + beatmapSetVideoBadge = new BeatmapSetHasVideoBadge + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } }, + Details = new Details(), }, }, @@ -218,6 +235,7 @@ namespace osu.Game.Overlays.BeatmapSet if (setInfo.NewValue == null) { onlineStatusPill.FadeTo(0.5f, 500, Easing.OutQuint); + beatmapSetVideoBadge.Hide(); fadeContent.Hide(); loading.Show(); @@ -235,6 +253,11 @@ namespace osu.Game.Overlays.BeatmapSet loading.Hide(); + if (setInfo.NewValue.HasVideo) + beatmapSetVideoBadge.Show(); + else + beatmapSetVideoBadge.Hide(); + var titleText = new RomanisableString(setInfo.NewValue.TitleUnicode, setInfo.NewValue.Title); var artistText = new RomanisableString(setInfo.NewValue.ArtistUnicode, setInfo.NewValue.Artist); From 25eb9914a437dbb09a5b2246eb11b7f95ab56e87 Mon Sep 17 00:00:00 2001 From: StanR Date: Sun, 22 Jun 2025 13:45:07 +0500 Subject: [PATCH 079/173] Switch to IconPills, add storyboard icon --- .../Online/TestSceneBeatmapSetOverlay.cs | 15 +++++- osu.Game/Beatmaps/Drawables/Cards/IconPill.cs | 6 +++ .../BeatmapSet/BeatmapSetHasVideoBadge.cs | 54 ------------------- .../BeatmapSet/BeatmapSetHeaderContent.cs | 31 +++++++++-- 4 files changed, 46 insertions(+), 60 deletions(-) delete mode 100644 osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 4cb9ec4e88..f36ef7a8e8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -244,7 +244,7 @@ namespace osu.Game.Tests.Visual.Online } [Test] - public void TestBeatmapSetHasVideo() + public void TestBeatmapSetHasVideoOrStoryboard() { AddStep("show beatmapset with video", () => { @@ -252,6 +252,19 @@ namespace osu.Game.Tests.Visual.Online beatmapSet.HasVideo = true; overlay.ShowBeatmapSet(beatmapSet); }); + AddStep("show beatmapset with storyboard", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.HasStoryboard = true; + overlay.ShowBeatmapSet(beatmapSet); + }); + AddStep("show beatmapset with video and storyboard", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.HasVideo = true; + beatmapSet.HasStoryboard = true; + overlay.ShowBeatmapSet(beatmapSet); + }); } [Test] diff --git a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs index 16be57ac95..7cdd50e7ea 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs @@ -20,6 +20,12 @@ namespace osu.Game.Beatmaps.Drawables.Cards set => iconContainer.Size = value; } + public MarginPadding IconPadding + { + get => iconContainer.Padding; + set => iconContainer.Padding = value; + } + private readonly Container iconContainer; protected IconPill(IconUsage icon) diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs deleted file mode 100644 index 7281b3970c..0000000000 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHasVideoBadge.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; -using osu.Game.Resources.Localisation.Web; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.BeatmapSet -{ - public partial class BeatmapSetHasVideoBadge : CircularContainer, IHasTooltip - { - public LocalisableString TooltipText => BeatmapsetsStrings.ShowInfoVideo; - - private readonly Box background; - - public BeatmapSetHasVideoBadge() - { - AutoSizeAxes = Axes.Both; - Masking = true; - Alpha = 0; - - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.5f - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Solid.Film, - Size = new Vector2(14), - Margin = new MarginPadding(10) - }, - }; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - background.Colour = colourProvider.Background6; - } - } -} diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index 7a48a02f58..8cdb644cab 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -46,7 +47,8 @@ namespace osu.Game.Overlays.BeatmapSet private readonly Box coverGradient; private readonly LinkFlowContainer title, artist; private readonly AuthorInfo author; - private readonly BeatmapSetHasVideoBadge beatmapSetVideoBadge; + private readonly VideoIconPill videoIconPill; + private readonly StoryboardIconPill storyboardIconPill; private ExternalLinkButton externalLink; @@ -191,10 +193,23 @@ namespace osu.Game.Overlays.BeatmapSet TextSize = 14, TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } }, - beatmapSetVideoBadge = new BeatmapSetHasVideoBadge + videoIconPill = new VideoIconPill { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + IconSize = new Vector2(34), + IconPadding = new MarginPadding(10), + }, + storyboardIconPill = new StoryboardIconPill + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + IconSize = new Vector2(34), + IconPadding = new MarginPadding(10), }, } }, @@ -235,7 +250,8 @@ namespace osu.Game.Overlays.BeatmapSet if (setInfo.NewValue == null) { onlineStatusPill.FadeTo(0.5f, 500, Easing.OutQuint); - beatmapSetVideoBadge.Hide(); + videoIconPill.Hide(); + storyboardIconPill.Hide(); fadeContent.Hide(); loading.Show(); @@ -254,9 +270,14 @@ namespace osu.Game.Overlays.BeatmapSet loading.Hide(); if (setInfo.NewValue.HasVideo) - beatmapSetVideoBadge.Show(); + videoIconPill.Show(); else - beatmapSetVideoBadge.Hide(); + videoIconPill.Hide(); + + if (setInfo.NewValue.HasStoryboard) + storyboardIconPill.Show(); + else + storyboardIconPill.Hide(); var titleText = new RomanisableString(setInfo.NewValue.TitleUnicode, setInfo.NewValue.Title); var artistText = new RomanisableString(setInfo.NewValue.ArtistUnicode, setInfo.NewValue.Artist); From ef9fed47a9e5ab8e0066216e2d007d4d8a1148ab Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 23 Jun 2025 00:41:12 +0300 Subject: [PATCH 080/173] Fix null reference in metadata wedge when accessing beatmap tags --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index b3d3bb6279..8d1dd105a3 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -1,6 +1,7 @@ // 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.Linq; using osu.Framework.Allocation; @@ -298,7 +299,11 @@ namespace osu.Game.Screens.SelectV2 else source.Data = ("-", null); - mapperTags.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); + if (!string.IsNullOrEmpty(metadata.Tags)) + mapperTags.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); + else + mapperTags.Tags = (Array.Empty(), _ => { }); + submitted.Date = beatmapSetInfo.DateSubmitted; ranked.Date = beatmapSetInfo.DateRanked; From 5436313c86c2e36fc0d6ed8cdaebb26da8024407 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 23 Jun 2025 05:26:12 +0300 Subject: [PATCH 081/173] Flip storyboard/video icon order --- osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index 8cdb644cab..f75e7b1d3c 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -193,7 +193,7 @@ namespace osu.Game.Overlays.BeatmapSet TextSize = 14, TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } }, - videoIconPill = new VideoIconPill + storyboardIconPill = new StoryboardIconPill { AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, @@ -202,7 +202,7 @@ namespace osu.Game.Overlays.BeatmapSet IconSize = new Vector2(34), IconPadding = new MarginPadding(10), }, - storyboardIconPill = new StoryboardIconPill + videoIconPill = new VideoIconPill { AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, From c61ec0b86a56111a77e81221ebd59333d2ac7a86 Mon Sep 17 00:00:00 2001 From: eyhn Date: Mon, 23 Jun 2025 13:37:11 +0800 Subject: [PATCH 082/173] Fix inconsistent rounding strategy for PP --- .../Toolbar/TransientUserStatisticsUpdateDisplay.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs index d5891da936..85b7358d2d 100644 --- a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs +++ b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs @@ -83,7 +83,12 @@ namespace osu.Game.Overlays.Toolbar } if (update.After.PP != null) - pp.Display((int)(update.Before.PP ?? update.After.PP.Value), (int)Math.Abs(((int?)update.After.PP - (int?)update.Before.PP) ?? 0M), (int)update.After.PP.Value); + { + int before = (int)Math.Round(update.Before.PP ?? update.After.PP.Value); + int after = (int)Math.Round(update.After.PP.Value); + int delta = Math.Abs(after - before); + pp.Display(before, delta, after); + } this.Delay(5000).FadeOut(500, Easing.OutQuint); }); From cef6445e2b62232d53731edad3cc1788cdb4264f Mon Sep 17 00:00:00 2001 From: jyc76 Date: Mon, 23 Jun 2025 16:15:47 +0900 Subject: [PATCH 083/173] Increase margin in PanelBeatmap to improve legibility --- osu.Game/Screens/SelectV2/PanelBeatmap.cs | 4 ++-- osu.Game/Screens/SelectV2/PanelBeatmapSet.cs | 4 +++- osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index 19ff8a0676..4af607c750 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -108,8 +108,8 @@ namespace osu.Game.Screens.SelectV2 AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Spacing = new Vector2(3), - Margin = new MarginPadding { Left = 5 }, + Spacing = new Vector2(5), + Margin = new MarginPadding { Left = 6.5f, Bottom = 3.5f }, Direction = FillDirection.Horizontal, Children = new Drawable[] { diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs index 9bdd188a3a..2864980fce 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -97,7 +97,9 @@ namespace osu.Game.Screens.SelectV2 { AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, - Padding = new MarginPadding { Top = 7.5f, Left = 15, Bottom = 5 }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Top = 7.5f, Left = 15, Bottom = 13 }, Children = new Drawable[] { titleText = new OsuSpriteText diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index 287af444ee..bae6483e50 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -98,8 +98,8 @@ namespace osu.Game.Screens.SelectV2 AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Spacing = new Vector2(3), - Margin = new MarginPadding { Left = 5 }, + Spacing = new Vector2(5), + Margin = new MarginPadding { Left = 6.5f, Bottom = 2.8f }, Direction = FillDirection.Horizontal, Children = new Drawable[] { From a9347ff8c32fc4e43ae6bdd84b3ba55d495110e7 Mon Sep 17 00:00:00 2001 From: jyc76 Date: Mon, 23 Jun 2025 23:31:20 +0900 Subject: [PATCH 084/173] Fix top and bottom spacing of the local rank display --- osu.Game/Screens/SelectV2/PanelBeatmap.cs | 3 ++- osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index 4af607c750..ca4ef56169 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -109,7 +109,7 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Spacing = new Vector2(5), - Margin = new MarginPadding { Left = 6.5f, Bottom = 3.5f }, + Margin = new MarginPadding { Left = 6.5f }, Direction = FillDirection.Horizontal, Children = new Drawable[] { @@ -125,6 +125,7 @@ namespace osu.Game.Screens.SelectV2 Origin = Anchor.CentreLeft, Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Bottom = 3.5f }, Children = new Drawable[] { new FillFlowContainer diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index bae6483e50..28a6bfc83a 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -99,7 +99,7 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Spacing = new Vector2(5), - Margin = new MarginPadding { Left = 6.5f, Bottom = 2.8f }, + Margin = new MarginPadding { Left = 6.5f }, Direction = FillDirection.Horizontal, Children = new Drawable[] { @@ -114,7 +114,7 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Direction = FillDirection.Vertical, - Padding = new MarginPadding { Bottom = 2 }, + Padding = new MarginPadding { Bottom = 4.8f }, AutoSizeAxes = Axes.Both, Children = new Drawable[] { From 96db5677ca21e392097f8690a86f5d5ff9824522 Mon Sep 17 00:00:00 2001 From: jyc76 Date: Mon, 23 Jun 2025 23:49:49 +0900 Subject: [PATCH 085/173] Use padding instead of margin in PanelBeatmap --- osu.Game/Screens/SelectV2/PanelBeatmap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index ca4ef56169..a06c77448f 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -125,7 +125,7 @@ namespace osu.Game.Screens.SelectV2 Origin = Anchor.CentreLeft, Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Bottom = 3.5f }, + Padding = new MarginPadding { Bottom = 3.5f }, Children = new Drawable[] { new FillFlowContainer From 58b9b49c78eec2def26259311f2828e74f363302 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 14:37:34 +0900 Subject: [PATCH 086/173] Fix spectator button not working when user is playing daily challenge --- osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs index 39df3ba22c..02fe681492 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs @@ -197,6 +197,7 @@ namespace osu.Game.Overlays.Dashboard case UserActivity.InSoloGame: case UserActivity.InMultiplayerGame: case UserActivity.InPlaylistGame: + case UserActivity.PlayingDailyChallenge: spectateButton.Enabled.Value = true; break; } From dbdf2d9aca43510fff73cfbdcd13a44b4e05a4a7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 14:54:52 +0900 Subject: [PATCH 087/173] Fix very short kiai sections not showing up on editor summary timeline Closes https://github.com/ppy/osu/issues/33836. --- .../Edit/Components/Timelines/Summary/Parts/KiaiPart.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs index ee44df8598..e856009817 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs @@ -1,6 +1,7 @@ // 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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; @@ -91,7 +92,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts section = value; X = (float)value.StartTime; - Width = (float)value.Duration; + // Minimum width ensures that very short kiai sections still show a slither of colour. + Width = (float)Math.Max(200, value.Duration); } } From 3599269cba188edf1e3aa5bb6ec5b0d3106b7134 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Tue, 24 Jun 2025 09:10:13 +0300 Subject: [PATCH 088/173] Fix dropdown search bar not having placeholder text --- osu.Game/Graphics/UserInterface/OsuDropdown.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index af335efdc4..e0179f8bc4 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -18,6 +18,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -440,6 +441,11 @@ namespace osu.Game.Graphics.UserInterface private partial class DropdownSearchTextBox : OsuTextBox { + public DropdownSearchTextBox() + { + PlaceholderText = HomeStrings.SearchPlaceholder; + } + [BackgroundDependencyLoader] private void load(OverlayColourProvider? colourProvider) { From c0a51da11054aba0ffbbbe98555097874cfd0a1e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 15:33:51 +0900 Subject: [PATCH 089/173] Fix player settings overlay potentially disappearing unexpectedly Closes https://github.com/ppy/osu/issues/33793. Can be tested with this diff: ```diff diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index 635d140a4a..b3a827b699 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -60,6 +60,8 @@ public PlayerSettingsOverlay() Origin = Anchor.TopRight; Anchor = Anchor.TopRight; + X = 0.01f; + base.Content.Add(content = new FillFlowContainer { AutoSizeAxes = Axes.Both, ``` --- osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index b285b1b799..635d140a4a 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -6,6 +6,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; @@ -47,6 +48,12 @@ namespace osu.Game.Screens.Play.HUD [Resolved] private HUDOverlay? hudOverlay { get; set; } + // Player settings are kept off the edge of the screen. + // + // In edge cases, floating point error could result in the whole control getting masked away + // while collapsed down, so let's avoid that. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + public PlayerSettingsOverlay() : base(0, EXPANDED_WIDTH) { From 0aec52a64ebef23676e50cca9801a045733df44f Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 15:57:53 +0900 Subject: [PATCH 090/173] Fix download requests firing too often in multiplayer spectator Closes https://github.com/ppy/osu/issues/33785. Tested on production with debugger attached. --- .../Multiplayer/Match/MultiplayerSpectateButton.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 13abe7bb14..46e25fc688 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -111,6 +111,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private CancellationTokenSource? downloadCheckCancellation; + private int? lastDownloadCheckedBeatmapId; + private void checkForAutomaticDownload() { downloadCheckCancellation?.Cancel(); @@ -132,6 +134,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + // This method is called every time anything changes in the room. + // This could result in download requests firing far too often, when we only expect them to fire once per beatmap. + // + // Without this check, we would see especially egregious behaviour when a user has hit the download rate limit. + if (lastDownloadCheckedBeatmapId == item.BeatmapID) + return; + + lastDownloadCheckedBeatmapId = item.BeatmapID; + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. beatmapLookupCache From 4123d4a80d9fa9086579c32ace68bd6787b16914 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 16:36:10 +0900 Subject: [PATCH 091/173] Fix rotating objects in the skin editor not rotating as expected Closes https://github.com/ppy/osu/issues/33845. Not sure if this ever worked but it was definitely wrong for a while now. --- osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs | 2 +- .../Screens/Edit/Compose/Components/SelectionRotationHandler.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs index 9fd28a1cad..c8799ad5ba 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.SkinEditor objectsInRotation = selectedItems.Cast().ToArray(); originalRotations = objectsInRotation.ToDictionary(d => d, d => d.Rotation); originalPositions = objectsInRotation.ToDictionary(d => d, d => d.ToScreenSpace(d.OriginPosition)); - DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre; + DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => ToLocalSpace(d.ScreenSpaceDrawQuad).GetVertices().ToArray())).Centre; base.Begin(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs index af3b3d6489..6cd2428b8a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs @@ -30,6 +30,8 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Implementation-defined origin point to rotate around when no explicit origin is provided. /// This field is only assigned during a rotation operation. + /// + /// Coordinates are in local space for this container. /// public Vector2? DefaultOrigin { get; protected set; } From a1ec71e677a35a4fb453340034db705a6ac629e4 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 17:48:38 +0900 Subject: [PATCH 092/173] Fix potential crash when attempting random selection after changing grouping mode --- .../TestSceneBeatmapCarouselRandom.cs | 17 +++++++++++++++++ osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 9 +++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs index 739fc23ed5..b7e169964d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs @@ -37,6 +37,23 @@ namespace osu.Game.Tests.Visual.SongSelectV2 } } + [Test] + public void TestGroupingModeChangeStillWorks() + { + SortAndGroupBy(SortMode.Artist, GroupMode.Artist); + AddBeatmaps(10, 3, true); + WaitForDrawablePanels(); + + nextRandom(); + ensureRandomDidNotRepeat(); + + SortAndGroupBy(SortMode.Artist, GroupMode.None); + WaitForFiltering(); + + nextRandom(); + ensureRandomDidNotRepeat(); + } + /// /// Test random non-repeating algorithm /// diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index cd5ab68e6f..ccd7e52ed1 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -289,9 +289,14 @@ namespace osu.Game.Screens.SelectV2 // This will update the visual state of the selected item. HandleItemSelected(CurrentSelection); - // If a group was selected that is not the one containing the selection, reselect it. + // If a group was selected that is not the one containing the selection, attempt to reselect it. if (groupForReselection != null) - setExpandedGroup(groupForReselection); + { + if (!grouping.GroupItems.TryGetValue(groupForReselection, out _)) + ExpandedGroup = null; + else + setExpandedGroup(groupForReselection); + } } private void selectRecommendedDifficultyForBeatmapSet(BeatmapSetInfo beatmapSet) From 4fbffc4d660c623cd14c6a2eacfd0d04653da1b2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 17:51:39 +0900 Subject: [PATCH 093/173] Move cancellation below new equality check --- .../OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 46e25fc688..3f207f6fa1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -115,8 +115,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void checkForAutomaticDownload() { - downloadCheckCancellation?.Cancel(); - if (client.Room == null) return; @@ -143,6 +141,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match lastDownloadCheckedBeatmapId = item.BeatmapID; + downloadCheckCancellation?.Cancel(); + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. beatmapLookupCache From a78dc31d8bc278bac25875ff75bcb07192c04cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Jun 2025 11:37:23 +0200 Subject: [PATCH 094/173] Add testing --- .../Menus/TestSceneToolbarUserButton.cs | 18 +++++++++++++ .../Visual/Ranking/TestSceneOverallRanking.cs | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs index 1af4af8f6b..53d909406f 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -150,6 +150,24 @@ namespace osu.Game.Tests.Visual.Menus }); }); + // cross-reference: `TestSceneOverallRanking.TestRoundingTreatment()`. + AddStep("Test rounding treatment", () => + { + var transientUpdateDisplay = this.ChildrenOfType().Single(); + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( + new ScoreInfo(), + new UserStatistics + { + GlobalRank = 111_111, + PP = 5071.495M + }, + new UserStatistics + { + GlobalRank = 111_111, + PP = 5072.99M + }); + }); + AddStep("No change 1", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs index fb18cc8a59..e49d23dd80 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs @@ -46,6 +46,32 @@ namespace osu.Game.Tests.Visual.Ranking }); } + // cross-reference: `TestSceneToolbarUserButton.TestTransientUserStatisticsDisplay()`, "Test rounding treatment" step. + [Test] + public void TestRoundingTreatment() + { + createDisplay(); + displayUpdate( + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_071.495M + }, + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072.99M + }); + } + [Test] public void TestAllDecreased() { From b1e86aa92f8b0465a33391ccb43f2a88682e379f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Jun 2025 11:42:14 +0200 Subject: [PATCH 095/173] Calculate pp difference post-rounding everywhere --- .../Statistics/User/PerformancePointsChangeRow.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs index c1faf1a3e3..3af1bdb860 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs @@ -1,25 +1,26 @@ // 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.Diagnostics; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Ranking.Statistics.User { - public partial class PerformancePointsChangeRow : RankingChangeRow + public partial class PerformancePointsChangeRow : RankingChangeRow { public PerformancePointsChangeRow() - : base(stats => stats.PP) + : base(stats => stats.PP != null ? (int)Math.Round(stats.PP.Value) : null) { } protected override LocalisableString Label => RankingsStrings.StatPerformance; - protected override LocalisableString FormatCurrentValue(decimal? current) + protected override LocalisableString FormatCurrentValue(int? current) => current == null ? string.Empty : LocalisableString.Interpolate($@"{current:N0}pp"); - protected override int CalculateDifference(decimal? previous, decimal? current, out LocalisableString formattedDifference) + protected override int CalculateDifference(int? previous, int? current, out LocalisableString formattedDifference) { if (previous == null && current == null) { From 7b6ecbd10b4bab72ab8a919a7d0399f126dc27e6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 19:21:04 +0900 Subject: [PATCH 096/173] Expand test to cover more potential weirdness and fix said weirdness --- .../TestSceneBeatmapCarouselRandom.cs | 23 ++++++++++++++++--- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 10 +++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs index b7e169964d..ef142e6253 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs @@ -40,6 +40,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestGroupingModeChangeStillWorks() { + BeatmapInfo originalSelected = null!; + GroupDefinition? expanded = null; + SortAndGroupBy(SortMode.Artist, GroupMode.Artist); AddBeatmaps(10, 3, true); WaitForDrawablePanels(); @@ -47,11 +50,25 @@ namespace osu.Game.Tests.Visual.SongSelectV2 nextRandom(); ensureRandomDidNotRepeat(); - SortAndGroupBy(SortMode.Artist, GroupMode.None); + AddStep("store selection", () => originalSelected = (BeatmapInfo)Carousel.CurrentSelection!); + + SortAndGroupBy(SortMode.Artist, GroupMode.Difficulty); WaitForFiltering(); - nextRandom(); - ensureRandomDidNotRepeat(); + AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(originalSelected)); + + storeExpandedGroup(); + + for (int i = 0; i < 5; i++) + { + nextRandom(); + ensureRandomDidNotRepeat(); + checkExpandedGroupUnchanged(); + } + + void storeExpandedGroup() => AddStep("store open group", () => expanded = Carousel.ExpandedGroup); + + void checkExpandedGroupUnchanged() => AddAssert("expanded did not change", () => Carousel.ExpandedGroup, () => Is.EqualTo(expanded)); } /// diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index ccd7e52ed1..c5fb363f3b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -290,13 +290,9 @@ namespace osu.Game.Screens.SelectV2 HandleItemSelected(CurrentSelection); // If a group was selected that is not the one containing the selection, attempt to reselect it. - if (groupForReselection != null) - { - if (!grouping.GroupItems.TryGetValue(groupForReselection, out _)) - ExpandedGroup = null; - else - setExpandedGroup(groupForReselection); - } + // If the original group was not found, ExpandedGroup will already have been updated to a valid value in `HandleItemSelected` above. + if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _)) + setExpandedGroup(groupForReselection); } private void selectRecommendedDifficultyForBeatmapSet(BeatmapSetInfo beatmapSet) From 54c2d4207fae1bb50d373f686130d40321dd459e Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Tue, 24 Jun 2025 14:11:27 +0300 Subject: [PATCH 097/173] Add link button to multiplayer/playlists room panels --- .../OnlinePlay/Lounge/Components/RoomPanel.cs | 37 +++++++++++++++++-- .../OnlinePlay/Lounge/LoungeRoomPanel.cs | 1 + 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index acbf5d8462..b94cfb8de7 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -54,11 +54,15 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected readonly Bindable SelectedItem = new Bindable(); protected Container ButtonsContainer { get; private set; } = null!; + protected bool ShowExternalLink { get; init; } = true; + private DrawableRoomParticipantsList? drawableRoomParticipantsList; private RoomSpecialCategoryPill? specialCategoryPill; private PasswordProtectedIcon? passwordIcon; private EndDateInfo? endDateInfo; + private FillFlowContainer? roomNameFlow; private SpriteText? roomName; + private ExternalLinkButton? linkButton; private DelayedLoadWrapper wrapper = null!; private CancellationTokenSource? beatmapLookupCancellation; @@ -204,10 +208,27 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - roomName = new TruncatingSpriteText + roomNameFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, - Font = OsuFont.GetFont(size: 28) + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + roomName = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 28), + }, + linkButton = new ExternalLinkButton(formatRoomUrl(Room.RoomID ?? 0)) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Horizontal = 6, Bottom = 4 }, + Alpha = ShowExternalLink && Room.RoomID.HasValue ? 1 : 0, + }, + }, }, new RoomStatusText(Room) { @@ -288,6 +309,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components SelectedItem.BindValueChanged(onSelectedItemChanged, true); } + protected override void Update() + { + base.Update(); + + if (roomName != null) + roomName.MaxWidth = (roomNameFlow?.DrawWidth ?? 0) - (linkButton?.LayoutSize.X ?? 0); + } + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) @@ -390,11 +419,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } return items.ToArray(); - - string formatRoomUrl(long id) => $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{id}"; } } + private string formatRoomUrl(long id) => $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{id}"; + protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite(); protected virtual IEnumerable CreateBottomDetails() diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs index 3ff27a14bb..12b38a9677 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs @@ -67,6 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public LoungeRoomPanel(Room room) : base(room) { + ShowExternalLink = false; } [BackgroundDependencyLoader] From 0de964b10b461609fdec36eb46c99116e3305d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Jun 2025 13:32:42 +0200 Subject: [PATCH 098/173] Expand test even further to cover even more potential weirdness --- .../SongSelectV2/TestSceneBeatmapCarouselRandom.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs index ef142e6253..ed694c9e3d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselRandom.cs @@ -66,6 +66,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkExpandedGroupUnchanged(); } + SortAndGroupBy(SortMode.Artist, GroupMode.None); + WaitForFiltering(); + + for (int i = 0; i < 5; i++) + { + nextRandom(); + ensureRandomDidNotRepeat(); + } + void storeExpandedGroup() => AddStep("store open group", () => expanded = Carousel.ExpandedGroup); void checkExpandedGroupUnchanged() => AddAssert("expanded did not change", () => Carousel.ExpandedGroup, () => Is.EqualTo(expanded)); From 8781901d6bc691a44d99e8bde1f0475afb44b79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 24 Jun 2025 13:38:32 +0200 Subject: [PATCH 099/173] Ensure expanded group is cleared when grouping is turned off --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index c5fb363f3b..e19cdd20c7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -267,8 +267,7 @@ namespace osu.Game.Screens.SelectV2 // Find any containing group. There should never be too many groups so iterating is efficient enough. GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => CheckModelEquality(i.Model, beatmapInfo))).Key; - if (containingGroup != null) - setExpandedGroup(containingGroup); + setExpandedGroup(containingGroup); if (grouping.BeatmapSetsGroupedTogether) setExpandedSet(beatmapInfo); @@ -362,8 +361,11 @@ namespace osu.Game.Screens.SelectV2 { if (ExpandedGroup != null) setExpansionStateOfGroup(ExpandedGroup, false); + ExpandedGroup = group; - setExpansionStateOfGroup(group, true); + + if (ExpandedGroup != null) + setExpansionStateOfGroup(group, true); } private void setExpansionStateOfGroup(GroupDefinition group, bool expanded) From bb15df1ba586b38f745ba9db56f56555d485a04f Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 11:13:05 +0900 Subject: [PATCH 100/173] Disallow changing release stream on fixed installs For package-managed solutions that set `OSU_EXTERNAL_UPDATE_STREAM` (which overrides the config value), we should not allow the user to change the release stream themselves. --- .../Localisation/GeneralSettingsStrings.cs | 18 +++++++---- .../Sections/General/UpdateSettings.cs | 21 +++++++++---- osu.Game/Overlays/Settings/SettingsItem.cs | 30 ++++++++++++------- osu.Game/Updater/MobileUpdateNotifier.cs | 3 ++ osu.Game/Updater/NoActionUpdateManager.cs | 2 ++ osu.Game/Updater/UpdateManager.cs | 2 ++ 6 files changed, 55 insertions(+), 21 deletions(-) diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 0f4dd0805e..7c9f78e57f 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -79,23 +79,31 @@ namespace osu.Game.Localisation /// public static LocalisableString LearnMoreAboutLazerTooltip => new TranslatableString(getKey(@"check_out_the_feature_comparison"), @"Check out the feature comparison and FAQ"); + /// + /// "Check with your package manager / provider for other release streams." + /// + public static LocalisableString ChangeReleaseStreamPackageManagerWarning => new TranslatableString(getKey(@"change_release stream_package_warning"), @"Check with your package manager / provider for other release streams."); + + /// + /// "Check with your app store (testflight, etc) for other release streams." + /// + public static LocalisableString ChangeReleaseStreamMobileWarning => new TranslatableString(getKey(@"change_release stream_mobile_warning"), @"Check with your app store (testflight, etc) for other release streams."); + /// /// "Are you sure you want to run a potentially unstable version of the game?" /// - public static LocalisableString ChangeReleaseStreamConfirmation => new TranslatableString(getKey(@"change_release stream_confirmation"), - @"Are you sure you want to run a potentially unstable version of the game?"); + public static LocalisableString ChangeReleaseStreamConfirmation => new TranslatableString(getKey(@"change_release stream_confirmation"), @"Are you sure you want to run a potentially unstable version of the game?"); /// /// "If you run into issues starting the game, you can usually run the installer from the official site to recover." /// - public static LocalisableString ChangeReleaseStreamConfirmationInfo => new TranslatableString(getKey(@"change_release stream_confirmation_info"), - @"If you run into issues starting the game, you can usually run the installer from the official site to recover."); + public static LocalisableString ChangeReleaseStreamConfirmationInfo => new TranslatableString(getKey(@"change_release stream_confirmation_info"), @"If you run into issues starting the game, you can usually run the installer from the official site to recover."); /// /// "You are running the latest release ({0})" /// public static LocalisableString RunningLatestRelease(string version) => new TranslatableString(getKey(@"running_latest_release"), @"You are running the latest release ({0})", version); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index e35fbaee76..63c09cde56 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -28,9 +28,9 @@ namespace osu.Game.Overlays.Settings.Sections.General protected override LocalisableString Header => GeneralSettingsStrings.UpdateHeader; private SettingsButton checkForUpdatesButton = null!; + private SettingsEnumDropdown releaseStreamDropdown = null!; private readonly Bindable configReleaseStream = new Bindable(); - private SettingsEnumDropdown releaseStreamDropdown = null!; [Resolved] private UpdateManager? updateManager { get; set; } @@ -58,11 +58,16 @@ namespace osu.Game.Overlays.Settings.Sections.General Keywords = new[] { @"version" }, }); - Add(checkForUpdatesButton = new SettingsButton + if (updateManager.FixedReleaseStream != null) { - Text = GeneralSettingsStrings.CheckUpdate, - Action = () => checkForUpdates().FireAndForget() - }); + configReleaseStream.Value = updateManager.FixedReleaseStream.Value; + + releaseStreamDropdown.ShowsDefaultIndicator = false; + releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; + releaseStreamDropdown.SetNoticeText(RuntimeInfo.IsDesktop + ? GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning + : GeneralSettingsStrings.ChangeReleaseStreamMobileWarning); + } releaseStreamDropdown.Current.BindValueChanged(stream => { @@ -86,6 +91,12 @@ namespace osu.Game.Overlays.Settings.Sections.General configReleaseStream.Value = stream.NewValue; }); + + Add(checkForUpdatesButton = new SettingsButton + { + Text = GeneralSettingsStrings.CheckUpdate, + Action = () => checkForUpdates().FireAndForget() + }); } if (RuntimeInfo.IsDesktop) diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 9c6bb5ae60..9186734641 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -45,7 +45,18 @@ namespace osu.Game.Overlays.Settings private OsuTextFlowContainer noticeText; - public bool ShowsDefaultIndicator = true; + private bool showsDefaultIndicator = true; + + public bool ShowsDefaultIndicator + { + get => showsDefaultIndicator; + set + { + showsDefaultIndicator = value; + defaultValueIndicatorContainer.Alpha = value ? 1 : 0; + } + } + private readonly Container defaultValueIndicatorContainer; public LocalisableString TooltipText { get; set; } @@ -214,17 +225,14 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load() { - // intentionally done before LoadComplete to avoid overhead. - if (ShowsDefaultIndicator) + defaultValueIndicatorContainer.Child = new RevertToDefaultButton { - defaultValueIndicatorContainer.Add(new RevertToDefaultButton - { - Current = controlWithCurrent.Current, - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }); - updateLayout(); - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = controlWithCurrent.Current, + }; + + updateLayout(); } private void updateLayout() diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 02dac00cf4..0b13830046 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -10,6 +10,7 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; +using osu.Game.Configuration; using osu.Game.Online.API; namespace osu.Game.Updater @@ -20,6 +21,8 @@ namespace osu.Game.Updater /// public partial class MobileUpdateNotifier : UpdateManager { + public override ReleaseStream? FixedReleaseStream => Configuration.ReleaseStream.Lazer; + private string version = null!; [Resolved] diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 06189b488c..2c6ae459de 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -17,6 +17,8 @@ namespace osu.Game.Updater /// public partial class NoActionUpdateManager : UpdateManager { + public override ReleaseStream? FixedReleaseStream => externalReleaseStream; + private static ReleaseStream? externalReleaseStream => Enum.TryParse(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_STREAM"), true, out ReleaseStream stream) ? stream : null; private string version = string.Empty; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index a9b00e8f93..65b4770174 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -32,6 +32,8 @@ namespace osu.Game.Updater // only implementations will actually check for updates. GetType() != typeof(UpdateManager); + public virtual ReleaseStream? FixedReleaseStream => null; + [Resolved] private OsuConfigManager config { get; set; } = null!; From c52c82ae8a53a607935cb2ad6bb6f62b6710e269 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 11:45:05 +0900 Subject: [PATCH 101/173] Support release streams in `MobileUpdateNotifier` --- osu.Game/Updater/MobileUpdateNotifier.cs | 20 ++++++++++++-------- osu.Game/Updater/NoActionUpdateManager.cs | 5 +---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 0b13830046..3a290c9a63 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -21,9 +21,10 @@ namespace osu.Game.Updater /// public partial class MobileUpdateNotifier : UpdateManager { - public override ReleaseStream? FixedReleaseStream => Configuration.ReleaseStream.Lazer; + public override ReleaseStream? FixedReleaseStream => stream; private string version = null!; + private ReleaseStream stream; [Resolved] private GameHost host { get; set; } = null!; @@ -31,22 +32,25 @@ namespace osu.Game.Updater [BackgroundDependencyLoader] private void load(OsuGameBase game) { - version = game.Version; + version = game.Version.Split('-').First(); + stream = Enum.TryParse(game.Version.Split('-').Last(), true, out ReleaseStream s) ? s : Configuration.ReleaseStream.Lazer; } protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { try { - var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); + bool includePrerelease = stream == Configuration.ReleaseStream.Tachyon; - await releases.PerformAsync(cancellationToken).ConfigureAwait(false); + OsuJsonWebRequest releasesRequest = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases?per_page=10&page=1"); + await releasesRequest.PerformAsync(cancellationToken).ConfigureAwait(false); - var latest = releases.ResponseObject; + GitHubRelease[] releases = releasesRequest.ResponseObject; + GitHubRelease? latest = releases.OrderByDescending(r => r.PublishedAt).FirstOrDefault(r => includePrerelease || !r.Prerelease); + + if (latest == null) + return false; - // avoid any discrepancies due to build suffixes for now. - // eventually we will want to support release streams and consider these. - version = version.Split('-').First(); string latestTagName = latest.TagName.Split('-').First(); if (latestTagName != version && tryGetBestUrl(latest, out string? url)) diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 2c6ae459de..0710797b60 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -26,7 +26,7 @@ namespace osu.Game.Updater [BackgroundDependencyLoader] private void load(OsuGameBase game) { - version = game.Version; + version = game.Version.Split('-').First(); } protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) @@ -45,9 +45,6 @@ namespace osu.Game.Updater if (latest == null) return false; - // avoid any discrepancies due to build suffixes for now. - // eventually we will want to support release streams and consider these. - version = version.Split('-').First(); string latestTagName = latest.TagName.Split('-').First(); if (latestTagName != version) From 37df9c5a48329649b65ecf14aab7503ca2c99af1 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 12:16:53 +0900 Subject: [PATCH 102/173] Fix incorrect localisation keys --- osu.Game/Localisation/GeneralSettingsStrings.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 7c9f78e57f..c806c4eb0a 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -82,22 +82,22 @@ namespace osu.Game.Localisation /// /// "Check with your package manager / provider for other release streams." /// - public static LocalisableString ChangeReleaseStreamPackageManagerWarning => new TranslatableString(getKey(@"change_release stream_package_warning"), @"Check with your package manager / provider for other release streams."); + public static LocalisableString ChangeReleaseStreamPackageManagerWarning => new TranslatableString(getKey(@"change_release_stream_package_warning"), @"Check with your package manager / provider for other release streams."); /// /// "Check with your app store (testflight, etc) for other release streams." /// - public static LocalisableString ChangeReleaseStreamMobileWarning => new TranslatableString(getKey(@"change_release stream_mobile_warning"), @"Check with your app store (testflight, etc) for other release streams."); + public static LocalisableString ChangeReleaseStreamMobileWarning => new TranslatableString(getKey(@"change_release_stream_mobile_warning"), @"Check with your app store (testflight, etc) for other release streams."); /// /// "Are you sure you want to run a potentially unstable version of the game?" /// - public static LocalisableString ChangeReleaseStreamConfirmation => new TranslatableString(getKey(@"change_release stream_confirmation"), @"Are you sure you want to run a potentially unstable version of the game?"); + public static LocalisableString ChangeReleaseStreamConfirmation => new TranslatableString(getKey(@"change_release_stream_confirmation"), @"Are you sure you want to run a potentially unstable version of the game?"); /// /// "If you run into issues starting the game, you can usually run the installer from the official site to recover." /// - public static LocalisableString ChangeReleaseStreamConfirmationInfo => new TranslatableString(getKey(@"change_release stream_confirmation_info"), @"If you run into issues starting the game, you can usually run the installer from the official site to recover."); + public static LocalisableString ChangeReleaseStreamConfirmationInfo => new TranslatableString(getKey(@"change_release_stream_confirmation_info"), @"If you run into issues starting the game, you can usually run the installer from the official site to recover."); /// /// "You are running the latest release ({0})" From d888572f7f9bab6fdcfe7708b8a66ae469e59f0d Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Wed, 25 Jun 2025 08:23:47 +0300 Subject: [PATCH 103/173] Move room name line to own component Fixes test failure caused by accessing components loaded asynchronously, see https://github.com/ppy/osu/actions/runs/15848940420/job/44677458262?pr=33858. --- .../OnlinePlay/Lounge/Components/RoomPanel.cs | 94 ++++++++++++------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index b94cfb8de7..7a4279ef98 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -17,6 +17,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Database; @@ -60,9 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private RoomSpecialCategoryPill? specialCategoryPill; private PasswordProtectedIcon? passwordIcon; private EndDateInfo? endDateInfo; - private FillFlowContainer? roomNameFlow; - private SpriteText? roomName; - private ExternalLinkButton? linkButton; + private RoomNameLine? roomName; private DelayedLoadWrapper wrapper = null!; private CancellationTokenSource? beatmapLookupCancellation; @@ -208,28 +207,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - roomNameFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - roomName = new TruncatingSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 28), - }, - linkButton = new ExternalLinkButton(formatRoomUrl(Room.RoomID ?? 0)) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Horizontal = 6, Bottom = 4 }, - Alpha = ShowExternalLink && Room.RoomID.HasValue ? 1 : 0, - }, - }, - }, + roomName = new RoomNameLine(getRoomUrl(), ShowExternalLink), new RoomStatusText(Room) { Beatmap = { BindTarget = currentBeatmap } @@ -309,14 +287,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components SelectedItem.BindValueChanged(onSelectedItemChanged, true); } - protected override void Update() - { - base.Update(); - - if (roomName != null) - roomName.MaxWidth = (roomNameFlow?.DrawWidth ?? 0) - (linkButton?.LayoutSize.X ?? 0); - } - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) @@ -413,8 +383,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components if (Room.RoomID.HasValue) { items.AddRange([ - new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(formatRoomUrl(Room.RoomID.Value))), - new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(formatRoomUrl(Room.RoomID.Value))) + new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(getRoomUrl())), + new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(getRoomUrl())) ]); } @@ -422,7 +392,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - private string formatRoomUrl(long id) => $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{id}"; + private string? getRoomUrl() => !Room.RoomID.HasValue ? null : $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{Room.RoomID.Value}"; protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite(); @@ -585,5 +555,57 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components }; } } + + public partial class RoomNameLine : FillFlowContainer + { + private readonly string? roomUrl; + private readonly bool showExternalLink; + + private TruncatingSpriteText spriteText = null!; + private ExternalLinkButton link = null!; + + public LocalisableString Text + { + get => spriteText.Text; + set => spriteText.Text = value; + } + + public RoomNameLine(string? roomUrl, bool showExternalLink) + { + this.roomUrl = roomUrl; + this.showExternalLink = showExternalLink; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Horizontal; + + Children = new Drawable[] + { + spriteText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 28), + }, + link = new ExternalLinkButton(roomUrl) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Horizontal = 6, Bottom = 4 }, + Alpha = showExternalLink ? 1 : 0, + }, + }; + } + + protected override void Update() + { + base.Update(); + spriteText.MaxWidth = DrawWidth - link.LayoutSize.X; + } + } } } From 9febf635f34d199db43bcd9f51cc1401de676ee8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 25 Jun 2025 14:29:57 +0900 Subject: [PATCH 104/173] Update framework 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 52cafa5c75..aa7b343f38 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 14863083f5..ab3fc11cca 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + From 8b542e5442f42ad132af0414a4f7191df9718a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 9 May 2025 10:43:44 +0200 Subject: [PATCH 105/173] Refactor hit windows class structure to reduce rigidity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change pulls back a significant degree of overspecialisation and rigidity in the class structure of `HitWindows` to make subsequent changes to hit windows, whose purpose is to improve replay playback accuracy, possible to do cleanly. Notably: - `HitWindows` is full abstract now. In a few use cases, and as a reference for ruleset implementors, `DefaultHitWindows` is provided as a separate class instead. This fixes the weirdness wherein `HitWindows` always declared 6 fields for result types but some of them would never be set to a non-zero value or read. - `HitWindow.GetRanges()` is deleted because it is overspecialised and prevents being able to adjust hitwindows by ±0.5ms cleanly which will be required later. The fallout of this is that the assertion that used `GetRanges()` in the `HitWindows` ctor must use something else now, and the closest thing to it was `GetAllAvailableWindows()`, which didn't return the miss window - so I made it return the miss window and fixed the one consumer that didn't want it (bar hit error meter) to skip it. - Diff also contains some clean-up around `DifficultyRange` to unify handling of it. --- .../Scoring/ManiaHitWindows.cs | 59 +++++++- .../TestSceneStartTimeOrderedHitPolicy.cs | 23 +++- osu.Game.Rulesets.Osu/Objects/Spinner.cs | 4 +- osu.Game.Rulesets.Osu/OsuRuleset.cs | 5 +- .../Scoring/OsuHitWindows.cs | 44 ++++-- .../Judgements/TestSceneHitJudgements.cs | 2 +- .../Scoring/TaikoHitWindows.cs | 40 +++++- osu.Game.Rulesets.Taiko/TaikoRuleset.cs | 5 +- .../TestSceneDrainingHealthProcessor.cs | 2 +- .../NonVisual/FirstAvailableHitWindowsTest.cs | 8 +- .../TestSceneGameplaySampleTriggerSource.cs | 10 +- osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs | 22 ++- osu.Game/Rulesets/Objects/HitObject.cs | 2 +- .../Rulesets/Scoring/DefaultHitWindows.cs | 66 +++++++++ osu.Game/Rulesets/Scoring/HitWindows.cs | 126 ++---------------- .../HUD/HitErrorMeters/BarHitErrorMeter.cs | 2 +- osu.Game/Screens/Utility/CircleGameplay.cs | 2 +- osu.Game/Screens/Utility/ScrollingGameplay.cs | 2 +- 18 files changed, 254 insertions(+), 170 deletions(-) create mode 100644 osu.Game/Rulesets/Scoring/DefaultHitWindows.cs diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs index 627f48f391..c0ba03d8ed 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs @@ -1,15 +1,30 @@ // 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; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { public class ManiaHitWindows : HitWindows { + private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D); + private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34); + private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67); + private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97); + private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121); + private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158); + private readonly double multiplier; + private double perfect; + private double great; + private double good; + private double ok; + private double meh; + private double miss; + public ManiaHitWindows() : this(1) { @@ -36,11 +51,41 @@ namespace osu.Game.Rulesets.Mania.Scoring return false; } - protected override DifficultyRange[] GetRanges() => base.GetRanges().Select(r => - new DifficultyRange( - r.Result, - r.Min * multiplier, - r.Average * multiplier, - r.Max * multiplier)).ToArray(); + public override void SetDifficulty(double difficulty) + { + perfect = IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range) * multiplier; + great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range) * multiplier; + good = IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range) * multiplier; + ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range) * multiplier; + meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range) * multiplier; + miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range) * multiplier; + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + return perfect; + + case HitResult.Great: + return great; + + case HitResult.Good: + return good; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 895e9bbdee..c637ed45f7 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -476,15 +476,24 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestHitWindows : HitWindows { - private static readonly DifficultyRange[] ranges = - { - new DifficultyRange(HitResult.Great, 500, 500, 500), - new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window), - }; - public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss; - protected override DifficultyRange[] GetRanges() => ranges; + public override void SetDifficulty(double difficulty) { } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Great: + return 500; + + case HitResult.Miss: + return early_miss_window; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } private partial class ScoreAccessibleReplayPlayer : ReplayPlayer diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index e3dfe8e69a..31d00a2610 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -21,12 +21,12 @@ namespace osu.Game.Rulesets.Osu.Objects /// /// The RPM required to clear the spinner at ODs [ 0, 5, 10 ]. /// - private static readonly (int min, int mid, int max) clear_rpm_range = (90, 150, 225); + private static readonly DifficultyRange clear_rpm_range = new DifficultyRange(90, 150, 225); /// /// The RPM required to complete the spinner and receive full score at ODs [ 0, 5, 10 ]. /// - private static readonly (int min, int mid, int max) complete_rpm_range = (250, 380, 430); + private static readonly DifficultyRange complete_rpm_range = new DifficultyRange(250, 380, 430); public double EndTime { diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 25b1dd9b12..0edb8046b9 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -373,10 +373,9 @@ namespace osu.Game.Rulesets.Osu preempt /= rate; adjustedDifficulty.ApproachRate = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); - var greatHitWindowRange = OsuHitWindows.OSU_RANGES.Single(range => range.Result == HitResult.Great); - double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, OsuHitWindows.GREAT_WINDOW_RANGE); greatHitWindow /= rate; - adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, OsuHitWindows.GREAT_WINDOW_RANGE); return adjustedDifficulty; } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs index fd86e0eeda..154503c20d 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -1,24 +1,26 @@ // 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 osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { public class OsuHitWindows : HitWindows { + public static readonly DifficultyRange GREAT_WINDOW_RANGE = new DifficultyRange(80, 50, 20); + public static readonly DifficultyRange OK_WINDOW_RANGE = new DifficultyRange(140, 100, 60); + public static readonly DifficultyRange MEH_WINDOW_RANGE = new DifficultyRange(200, 150, 100); + /// /// osu! ruleset has a fixed miss window regardless of difficulty settings. /// public const double MISS_WINDOW = 400; - internal static readonly DifficultyRange[] OSU_RANGES = - { - new DifficultyRange(HitResult.Great, 80, 50, 20), - new DifficultyRange(HitResult.Ok, 140, 100, 60), - new DifficultyRange(HitResult.Meh, 200, 150, 100), - new DifficultyRange(HitResult.Miss, MISS_WINDOW, MISS_WINDOW, MISS_WINDOW), - }; + private double great; + private double ok; + private double meh; public override bool IsHitResultAllowed(HitResult result) { @@ -34,6 +36,32 @@ namespace osu.Game.Rulesets.Osu.Scoring return false; } - protected override DifficultyRange[] GetRanges() => OSU_RANGES; + public override void SetDifficulty(double difficulty) + { + great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE); + ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE); + meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, MEH_WINDOW_RANGE); + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Great: + return great; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return MISS_WINDOW; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs index 6fe61e78b7..7008d8d37a 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs @@ -171,7 +171,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 6 }); beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 10 }); - var hitWindows = new HitWindows(); + var hitWindows = new DefaultHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); PerformTest(new List diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs index b44ef8ee93..22d268de3b 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs @@ -1,18 +1,21 @@ // 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 osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Scoring { public class TaikoHitWindows : HitWindows { - internal static readonly DifficultyRange[] TAIKO_RANGES = - { - new DifficultyRange(HitResult.Great, 50, 35, 20), - new DifficultyRange(HitResult.Ok, 120, 80, 50), - new DifficultyRange(HitResult.Miss, 135, 95, 70), - }; + public static readonly DifficultyRange GREAT_WINDOW_RANGE = new DifficultyRange(50, 35, 20); + public static readonly DifficultyRange OK_WINDOW_RANGE = new DifficultyRange(120, 80, 50); + public static readonly DifficultyRange MISS_WINDOW_RANGE = new DifficultyRange(135, 95, 70); + + private double great; + private double ok; + private double miss; public override bool IsHitResultAllowed(HitResult result) { @@ -27,6 +30,29 @@ namespace osu.Game.Rulesets.Taiko.Scoring return false; } - protected override DifficultyRange[] GetRanges() => TAIKO_RANGES; + public override void SetDifficulty(double difficulty) + { + great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE); + ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE); + miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, MISS_WINDOW_RANGE); + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Great: + return great; + + case HitResult.Ok: + return ok; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 8cc14ca651..1cb41e1299 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -274,10 +274,9 @@ namespace osu.Game.Rulesets.Taiko { BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty); - var greatHitWindowRange = TaikoHitWindows.TAIKO_RANGES.Single(range => range.Result == HitResult.Great); - double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, TaikoHitWindows.GREAT_WINDOW_RANGE); greatHitWindow /= rate; - adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, TaikoHitWindows.GREAT_WINDOW_RANGE); return adjustedDifficulty; } diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs index 584a9e09c0..18030d7222 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -359,7 +359,7 @@ namespace osu.Game.Tests.Gameplay } public override Judgement CreateJudgement() => new TestJudgement(maxResult); - protected override HitWindows CreateHitWindows() => new HitWindows(); + protected override HitWindows CreateHitWindows() => new DefaultHitWindows(); private class TestJudgement : Judgement { diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index 07d6d68e82..cfe523fdd5 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -33,7 +33,7 @@ namespace osu.Game.Tests.NonVisual public void TestResultIfOnlyParentHitWindowIsEmpty() { var testObject = new TestHitObject(HitWindows.Empty); - HitObject nested = new TestHitObject(new HitWindows()); + HitObject nested = new TestHitObject(new DefaultHitWindows()); testObject.AddNested(nested); testDrawableRuleset.HitObjects = new List { testObject }; @@ -43,8 +43,8 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestResultIfParentHitWindowsIsNotEmpty() { - var testObject = new TestHitObject(new HitWindows()); - HitObject nested = new TestHitObject(new HitWindows()); + var testObject = new TestHitObject(new DefaultHitWindows()); + HitObject nested = new TestHitObject(new DefaultHitWindows()); testObject.AddNested(nested); testDrawableRuleset.HitObjects = new List { testObject }; @@ -58,7 +58,7 @@ namespace osu.Game.Tests.NonVisual HitObject nested = new TestHitObject(HitWindows.Empty); firstObject.AddNested(nested); - var secondObject = new TestHitObject(new HitWindows()); + var secondObject = new TestHitObject(new DefaultHitWindows()); testDrawableRuleset.HitObjects = new List { firstObject, secondObject }; Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index 6981591193..894b51ddcb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -65,30 +65,30 @@ namespace osu.Game.Tests.Visual.Gameplay { new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }, new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) } }, new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) }, }, new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, }, new Slider { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, Vector2.UnitY * 200 }), Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) }, diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs index 48f6564084..2dd73a2541 100644 --- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs @@ -92,8 +92,8 @@ namespace osu.Game.Beatmaps /// /// /// Value to which the difficulty value maps in the specified range. - static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range) - => DifficultyRange(difficulty, range.od0, range.od5, range.od10); + static double DifficultyRange(double difficulty, DifficultyRange range) + => DifficultyRange(difficulty, range.Min, range.Mid, range.Max); /// /// Inverse function to . @@ -110,5 +110,23 @@ namespace osu.Game.Beatmaps ? (difficultyValue - diff5) / (diff10 - diff5) * 5 + 5 : (difficultyValue - diff5) / (diff5 - diff0) * 5 + 5; } + + /// + /// Inverse function to . + /// Maps a value returned by the function above back to the difficulty that produced it. + /// + /// The difficulty-dependent value to be unmapped. + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// Value to which the difficulty value maps in the specified range. + static double InverseDifficultyRange(double difficultyValue, DifficultyRange range) + => InverseDifficultyRange(difficultyValue, range.Min, range.Mid, range.Max); } + + /// + /// Represents a piecewise-linear difficulty curve for a given gameplay quantity. + /// + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. + /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + public record struct DifficultyRange(double Min, double Mid, double Max); } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 07e07b25d3..61c6c9f46f 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -192,7 +192,7 @@ namespace osu.Game.Rulesets.Objects /// /// [NotNull] - protected virtual HitWindows CreateHitWindows() => new HitWindows(); + protected virtual HitWindows CreateHitWindows() => new DefaultHitWindows(); /// /// The maximum offset from the end time of at which this can be judged. diff --git a/osu.Game/Rulesets/Scoring/DefaultHitWindows.cs b/osu.Game/Rulesets/Scoring/DefaultHitWindows.cs new file mode 100644 index 0000000000..3048233335 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/DefaultHitWindows.cs @@ -0,0 +1,66 @@ +// 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 osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// An example implementation of . + /// Not meaningfully used, provided mostly as a reference to ruleset implementors. + /// + public class DefaultHitWindows : HitWindows + { + private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D); + private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34); + private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67); + private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97); + private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121); + private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158); + + private double perfect; + private double great; + private double good; + private double ok; + private double meh; + private double miss; + + public override void SetDifficulty(double difficulty) + { + perfect = IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range); + great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range); + good = IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range); + ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range); + meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range); + miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range); + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + return perfect; + + case HitResult.Great: + return great; + + case HitResult.Good: + return good; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } + } +} diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index 94ea51c0b2..e1429f32b2 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Scoring @@ -13,35 +12,19 @@ namespace osu.Game.Rulesets.Scoring /// /// A structure containing timing data for hit window based gameplay. /// - public class HitWindows + public abstract class HitWindows { - private static readonly DifficultyRange[] base_ranges = - { - new DifficultyRange(HitResult.Perfect, 22.4D, 19.4D, 13.9D), - new DifficultyRange(HitResult.Great, 64, 49, 34), - new DifficultyRange(HitResult.Good, 97, 82, 67), - new DifficultyRange(HitResult.Ok, 127, 112, 97), - new DifficultyRange(HitResult.Meh, 151, 136, 121), - new DifficultyRange(HitResult.Miss, 188, 173, 158), - }; - - private double perfect; - private double great; - private double good; - private double ok; - private double meh; - private double miss; - /// /// An empty with only and . /// No time values are provided (meaning instantaneous hit or miss). /// public static HitWindows Empty { get; } = new EmptyHitWindows(); - public HitWindows() + protected HitWindows() { - Debug.Assert(GetRanges().Any(r => r.Result == HitResult.Miss), $"{nameof(GetRanges)} should always contain {nameof(HitResult.Miss)}"); - Debug.Assert(GetRanges().Any(r => r.Result != HitResult.Miss), $"{nameof(GetRanges)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); + var availableWindows = GetAllAvailableWindows(); + Debug.Assert(availableWindows.Any(r => r.result == HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain {nameof(HitResult.Miss)}"); + Debug.Assert(availableWindows.Any(r => r.result != HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); } /// @@ -64,7 +47,7 @@ namespace osu.Game.Rulesets.Scoring /// public IEnumerable<(HitResult result, double length)> GetAllAvailableWindows() { - for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result) + for (var result = HitResult.Miss; result <= HitResult.Perfect; ++result) { if (IsHitResultAllowed(result)) yield return (result, WindowFor(result)); @@ -82,40 +65,7 @@ namespace osu.Game.Rulesets.Scoring /// Sets hit windows with values that correspond to a difficulty parameter. /// /// The parameter. - public void SetDifficulty(double difficulty) - { - foreach (var range in GetRanges()) - { - double value = IBeatmapDifficultyInfo.DifficultyRange(difficulty, (range.Min, range.Average, range.Max)); - - switch (range.Result) - { - case HitResult.Miss: - miss = value; - break; - - case HitResult.Meh: - meh = value; - break; - - case HitResult.Ok: - ok = value; - break; - - case HitResult.Good: - good = value; - break; - - case HitResult.Great: - great = value; - break; - - case HitResult.Perfect: - perfect = value; - break; - } - } - } + public abstract void SetDifficulty(double difficulty); /// /// Retrieves the for a time offset. @@ -141,35 +91,7 @@ namespace osu.Game.Rulesets.Scoring /// /// The expected . /// One half of the hit window for . - public double WindowFor(HitResult result) - { - if (!IsHitResultAllowed(result)) - throw new ArgumentOutOfRangeException(nameof(result), result, $@"{result} is not an allowed result."); - - switch (result) - { - case HitResult.Perfect: - return perfect; - - case HitResult.Great: - return great; - - case HitResult.Good: - return good; - - case HitResult.Ok: - return ok; - - case HitResult.Meh: - return meh; - - case HitResult.Miss: - return miss; - - default: - throw new ArgumentOutOfRangeException(nameof(result), result, null); - } - } + public abstract double WindowFor(HitResult result); /// /// Given a time offset, whether the can ever be hit in the future with a non- result. @@ -179,41 +101,13 @@ namespace osu.Game.Rulesets.Scoring /// Whether the can be hit at any point in the future from this time offset. public bool CanBeHit(double timeOffset) => timeOffset <= WindowFor(LowestSuccessfulHitResult()); - /// - /// Retrieve a valid list of s representing hit windows. - /// Defaults are provided but can be overridden to customise for a ruleset. - /// - protected virtual DifficultyRange[] GetRanges() => base_ranges; - private class EmptyHitWindows : HitWindows { - private static readonly DifficultyRange[] ranges = - { - new DifficultyRange(HitResult.Perfect, 0, 0, 0), - new DifficultyRange(HitResult.Miss, 0, 0, 0), - }; - public override bool IsHitResultAllowed(HitResult result) => true; - protected override DifficultyRange[] GetRanges() => ranges; - } - } + public override void SetDifficulty(double difficulty) { } - public struct DifficultyRange - { - public readonly HitResult Result; - - public double Min; - public double Average; - public double Max; - - public DifficultyRange(HitResult result, double min, double average, double max) - { - Result = result; - - Min = min; - Average = average; - Max = max; + public override double WindowFor(HitResult result) => 0; } } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index a71a46ec2a..e27a7544c9 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -87,7 +87,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters const int bar_width = 2; const float chevron_size = 8; - hitWindows = HitWindows.GetAllAvailableWindows().ToArray(); + hitWindows = HitWindows.GetAllAvailableWindows().Where(w => w.result.IsHit()).ToArray(); InternalChild = new Container { diff --git a/osu.Game/Screens/Utility/CircleGameplay.cs b/osu.Game/Screens/Utility/CircleGameplay.cs index 0f328d04fb..c5c4d7d5b2 100644 --- a/osu.Game/Screens/Utility/CircleGameplay.cs +++ b/osu.Game/Screens/Utility/CircleGameplay.cs @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Utility HitEvent = new HitEvent(Clock.CurrentTime - HitTime, 1.0, HitResult.Good, new HitObject { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), }, null, null); Hit?.Invoke(HitEvent.Value); diff --git a/osu.Game/Screens/Utility/ScrollingGameplay.cs b/osu.Game/Screens/Utility/ScrollingGameplay.cs index c0264f5734..3e0969b625 100644 --- a/osu.Game/Screens/Utility/ScrollingGameplay.cs +++ b/osu.Game/Screens/Utility/ScrollingGameplay.cs @@ -188,7 +188,7 @@ namespace osu.Game.Screens.Utility HitEvent = new HitEvent(Clock.CurrentTime - HitTime, 1.0, HitResult.Good, new HitObject { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), }, null, null); Hit?.Invoke(HitEvent.Value); From f1e23595e7024f326b1750494d05cd58cd1d59ef Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 25 Jun 2025 15:45:07 +0900 Subject: [PATCH 106/173] Fix flaky carousel test due to out of range async filter operation See https://github.com/ppy/osu/actions/runs/15868654672/job/44740248052?pr=33873#step:5:49. --- .../Visual/SongSelectV2/BeatmapCarouselTestScene.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index 69b5de09c2..bc507fbffa 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -273,9 +273,13 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { var groupingFilter = Carousel.Filters.OfType().Single(); - GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(group); + GroupDefinition? groupDefinition = groupingFilter.GroupItems.Keys.ElementAtOrDefault(group); + + if (groupDefinition == null) + return false; + // offset by one because the group itself is included in the items list. - CarouselItem item = groupingFilter.GroupItems[g].ElementAt(panel + 1); + CarouselItem item = groupingFilter.GroupItems[groupDefinition].ElementAt(panel + 1); return (Carousel.CurrentSelection as BeatmapInfo)? .Equals(item.Model as BeatmapInfo) == true; From 57bfb378887f4ab588f412ea2e99c045b21298f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 25 Jun 2025 09:46:54 +0200 Subject: [PATCH 107/173] Add failing test --- .../TestSceneSongSelectNavigation.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs index 14dbd7981c..676be8fccf 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; @@ -168,6 +169,30 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("mod configured", () => ((OsuModMagnetised)Game.SelectedMods.Value.Single()).AttractionStrength.Value, () => Is.EqualTo(1.0f)); } + [Test] + public void TestLeaderboardCorrectInPlayer() + { + IWorkingBeatmap beatmap() => Game.Beatmap.Value; + + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + AddStep("switch to next difficulty and immediately press enter", () => + { + InputManager.Key(Key.Down); + Schedule(() => InputManager.Key(Key.Enter)); + }); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + AddAssert("leaderboard matches gameplay beatmap", () => Game.ChildrenOfType().Single().CurrentCriteria?.Beatmap, () => Is.EqualTo(beatmap().BeatmapInfo)); + } + private Func playToResults() { var player = playToCompletion(); From dee4ede306118431bc4f761d53492dc4d49f1dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 25 Jun 2025 09:50:36 +0200 Subject: [PATCH 108/173] Ensure global leaderboard state matches beatmap when loading player Closes https://github.com/ppy/osu/issues/33835. I love fixing issues multiple times. Notably the refetch is not forced so this should be a no-op if the global state is already correct. https://github.com/ppy/osu/issues/33835#issuecomment-2998897932 says > but we should also consider adding a force `RunTask` of the pending debounce before entering gameplay, probably around here: > > https://github.com/ppy/osu/blob/3192eaa2a20f20495db8431d3d6f35af7c705a94/osu.Game/Screens/SelectV2/SongSelect.cs#L420 but I am not confident in making that change as I have no idea whether it is correct or not. --- osu.Game/Screens/Play/PlayerLoader.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 27b6413d0c..f1a31b809f 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -26,12 +26,14 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input; using osu.Game.Localisation; +using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Volume; using osu.Game.Performance; using osu.Game.Screens.Menu; using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Users; using osu.Game.Utils; @@ -175,6 +177,9 @@ namespace osu.Game.Screens.Play [Resolved] private IHighPerformanceSessionManager? highPerformanceSessionManager { get; set; } + [Resolved] + private LeaderboardManager? leaderboardManager { get; set; } + public PlayerLoader(Func createPlayer) { this.createPlayer = createPlayer; @@ -269,6 +274,12 @@ namespace osu.Game.Screens.Play showStoryboards.BindValueChanged(val => epilepsyWarning?.FadeTo(val.NewValue ? 1 : 0, 250, Easing.OutQuint), true); epilepsyWarning?.FinishTransforms(true); + + leaderboardManager?.FetchWithCriteria(new LeaderboardCriteria( + Beatmap.Value.BeatmapInfo, + Ruleset.Value, + leaderboardManager?.CurrentCriteria?.Scope ?? BeatmapLeaderboardScope.Global, + leaderboardManager?.CurrentCriteria?.ExactMods)); } #region Screen handling From 55ff5a744cad9d7efdf40b7d0117958bc22bb8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 25 Jun 2025 10:05:46 +0200 Subject: [PATCH 109/173] Make assertion completely dead in release mode --- osu.Game/Rulesets/Scoring/HitWindows.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index e1429f32b2..f4d1fe1e14 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -22,9 +22,16 @@ namespace osu.Game.Rulesets.Scoring protected HitWindows() { - var availableWindows = GetAllAvailableWindows(); + ensureValidHitWindows(); + } + + [Conditional("DEBUG")] + private void ensureValidHitWindows() + { + var availableWindows = GetAllAvailableWindows().ToList(); Debug.Assert(availableWindows.Any(r => r.result == HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain {nameof(HitResult.Miss)}"); - Debug.Assert(availableWindows.Any(r => r.result != HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); + Debug.Assert(availableWindows.Any(r => r.result != HitResult.Miss), + $"{nameof(GetAllAvailableWindows)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); } /// From 8fb8772282215a3af3a13e46c44f9ad3ab6fd328 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 17:13:39 +0900 Subject: [PATCH 110/173] Hide entire section on mobile instead --- osu.Game/Localisation/GeneralSettingsStrings.cs | 5 ----- .../Overlays/Settings/Sections/General/UpdateSettings.cs | 6 ++---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index c806c4eb0a..20db5983fd 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -84,11 +84,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ChangeReleaseStreamPackageManagerWarning => new TranslatableString(getKey(@"change_release_stream_package_warning"), @"Check with your package manager / provider for other release streams."); - /// - /// "Check with your app store (testflight, etc) for other release streams." - /// - public static LocalisableString ChangeReleaseStreamMobileWarning => new TranslatableString(getKey(@"change_release_stream_mobile_warning"), @"Check with your app store (testflight, etc) for other release streams."); - /// /// "Are you sure you want to run a potentially unstable version of the game?" /// diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 63c09cde56..156a3db6eb 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -49,7 +49,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { config.BindWith(OsuSetting.ReleaseStream, configReleaseStream); - if (updateManager?.CanCheckForUpdate == true) + if (updateManager?.CanCheckForUpdate == true && !RuntimeInfo.IsMobile) { Add(releaseStreamDropdown = new SettingsEnumDropdown { @@ -64,9 +64,7 @@ namespace osu.Game.Overlays.Settings.Sections.General releaseStreamDropdown.ShowsDefaultIndicator = false; releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; - releaseStreamDropdown.SetNoticeText(RuntimeInfo.IsDesktop - ? GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning - : GeneralSettingsStrings.ChangeReleaseStreamMobileWarning); + releaseStreamDropdown.SetNoticeText(GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning); } releaseStreamDropdown.Current.BindValueChanged(stream => From e05c716d70af2cd72e8c60e7f8703bf4cd2d9a7e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 25 Jun 2025 17:35:57 +0900 Subject: [PATCH 111/173] Always show update button on mobile so section isn't empty --- .../Sections/General/UpdateSettings.cs | 76 ++++++++++--------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 156a3db6eb..ea18e5fd19 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -44,51 +44,40 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved] private OsuGame? game { get; set; } + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + [BackgroundDependencyLoader] - private void load(OsuConfigManager config, IDialogOverlay? dialogOverlay) + private void load(OsuConfigManager config) { config.BindWith(OsuSetting.ReleaseStream, configReleaseStream); - if (updateManager?.CanCheckForUpdate == true && !RuntimeInfo.IsMobile) + bool isDesktop = RuntimeInfo.IsDesktop; + bool canCheckUpdates = updateManager?.CanCheckForUpdate == true; + + if (canCheckUpdates) { - Add(releaseStreamDropdown = new SettingsEnumDropdown + // For simplicity, hide the concept of release streams from mobile users. + if (isDesktop) { - LabelText = GeneralSettingsStrings.ReleaseStream, - Current = { Value = configReleaseStream.Value }, - Keywords = new[] { @"version" }, - }); - - if (updateManager.FixedReleaseStream != null) - { - configReleaseStream.Value = updateManager.FixedReleaseStream.Value; - - releaseStreamDropdown.ShowsDefaultIndicator = false; - releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; - releaseStreamDropdown.SetNoticeText(GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning); - } - - releaseStreamDropdown.Current.BindValueChanged(stream => - { - if (stream.NewValue == ReleaseStream.Tachyon) + Add(releaseStreamDropdown = new SettingsEnumDropdown { - dialogOverlay?.Push(new ConfirmDialog(GeneralSettingsStrings.ChangeReleaseStreamConfirmation, - () => - { - configReleaseStream.Value = ReleaseStream.Tachyon; - }, - () => - { - releaseStreamDropdown.Current.Value = ReleaseStream.Lazer; - }) - { - BodyText = GeneralSettingsStrings.ChangeReleaseStreamConfirmationInfo - }); + LabelText = GeneralSettingsStrings.ReleaseStream, + Current = { Value = configReleaseStream.Value }, + Keywords = new[] { @"version" }, + }); - return; + if (updateManager!.FixedReleaseStream != null) + { + configReleaseStream.Value = updateManager.FixedReleaseStream.Value; + + releaseStreamDropdown.ShowsDefaultIndicator = false; + releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; + releaseStreamDropdown.SetNoticeText(GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning); } - configReleaseStream.Value = stream.NewValue; - }); + releaseStreamDropdown.Current.BindValueChanged(releaseStreamChanged); + } Add(checkForUpdatesButton = new SettingsButton { @@ -97,7 +86,8 @@ namespace osu.Game.Overlays.Settings.Sections.General }); } - if (RuntimeInfo.IsDesktop) + // Loosely update-related maintenance buttons. + if (isDesktop) { Add(new SettingsButton { @@ -121,6 +111,20 @@ namespace osu.Game.Overlays.Settings.Sections.General } } + private void releaseStreamChanged(ValueChangedEvent stream) + { + if (stream.NewValue == ReleaseStream.Tachyon) + { + dialogOverlay?.Push( + new ConfirmDialog(GeneralSettingsStrings.ChangeReleaseStreamConfirmation, () => { configReleaseStream.Value = ReleaseStream.Tachyon; }, + () => { releaseStreamDropdown.Current.Value = ReleaseStream.Lazer; }) { BodyText = GeneralSettingsStrings.ChangeReleaseStreamConfirmationInfo }); + + return; + } + + configReleaseStream.Value = stream.NewValue; + } + private async Task checkForUpdates() { if (updateManager == null || game == null) From a369061c15f45f02126e673f844e416024abcdcf Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 17:41:04 +0900 Subject: [PATCH 112/173] Fix code quality issue --- .../Overlays/Settings/Sections/General/UpdateSettings.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index ea18e5fd19..f0428a4c92 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -116,8 +116,12 @@ namespace osu.Game.Overlays.Settings.Sections.General if (stream.NewValue == ReleaseStream.Tachyon) { dialogOverlay?.Push( - new ConfirmDialog(GeneralSettingsStrings.ChangeReleaseStreamConfirmation, () => { configReleaseStream.Value = ReleaseStream.Tachyon; }, - () => { releaseStreamDropdown.Current.Value = ReleaseStream.Lazer; }) { BodyText = GeneralSettingsStrings.ChangeReleaseStreamConfirmationInfo }); + new ConfirmDialog(GeneralSettingsStrings.ChangeReleaseStreamConfirmation, + () => configReleaseStream.Value = ReleaseStream.Tachyon, + () => releaseStreamDropdown.Current.Value = ReleaseStream.Lazer) + { + BodyText = GeneralSettingsStrings.ChangeReleaseStreamConfirmationInfo + }); return; } From 49a9652fa5359732d95dcff8e9caf0a93222f274 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 25 Jun 2025 17:58:54 +0900 Subject: [PATCH 113/173] Rewrite and add commentary to selection debounce logic Hopefully a bit easier to maintain going forward? Not sure. --- osu.Game/Screens/SelectV2/SongSelect.cs | 43 +++++++++++++++++-------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 744c990317..4c30662bd4 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -233,8 +233,8 @@ namespace osu.Game.Screens.SelectV2 BleedBottom = ScreenFooter.HEIGHT + 5, RelativeSizeAxes = Axes.Both, RequestPresentBeatmap = b => SelectAndRun(b, OnStart), - RequestSelection = selectBeatmap, - RequestRecommendedSelection = selectRecommendedBeatmap, + RequestSelection = queueBeatmapSelection, + RequestRecommendedSelection = b => queueBeatmapSelection(difficultyRecommender?.GetRecommendedBeatmap(b) ?? b.First()), NewItemsPresented = newItemsPresented, }, noResultsPlaceholder = new NoResultsPlaceholder @@ -417,8 +417,6 @@ namespace osu.Game.Screens.SelectV2 /// The action to perform if conditions are met to be able to proceed. May not be invoked if in an invalid state. public void SelectAndRun(BeatmapInfo beatmap, Action startAction) { - selectionDebounce?.Cancel(); - if (!this.IsCurrentScreen()) return; @@ -427,6 +425,10 @@ namespace osu.Game.Screens.SelectV2 if (!checkBeatmapValidForSelection(beatmap, carousel.Criteria)) return; + // To ensure sanity, cancel any pending selection as we are about to force a selection. + // Carousel selection will update to the forced selection via a call of `ensureGlobalBeatmapValid` below, or when song select becomes current again. + selectionDebounce?.Cancel(); + // Forced refetch is important here to guarantee correct invalidation across all difficulties (editor specific). Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, true); @@ -439,12 +441,18 @@ namespace osu.Game.Screens.SelectV2 startAction(); } - private void selectRecommendedBeatmap(IEnumerable beatmaps) - { - selectBeatmap(difficultyRecommender?.GetRecommendedBeatmap(beatmaps) ?? beatmaps.First()); - } - - private void selectBeatmap(BeatmapInfo beatmap) + /// + /// Prepares the proposed beatmap for global selection based on a carousel user-performed action. + /// + /// + /// Calling this method will: + /// - Immediately update the selection the carousel. + /// - After , update the global beatmap. This in turn causes song select visuals (title, details, leaderboard) to update. + /// This debounce is intended to avoid high overheads from churning lookups while a user is changing selection via rapid keyboard operations. + /// To complete the operation immediately, call . + /// + /// The beatmap to be selected. + private void queueBeatmapSelection(BeatmapInfo beatmap) { if (!this.IsCurrentScreen()) return; @@ -462,13 +470,21 @@ namespace osu.Game.Screens.SelectV2 }, SELECTION_DEBOUNCE); } + /// + /// If any pending selection exists from , run it immediately. + /// + private void finaliseBeatmapSelection() + { + if (selectionDebounce?.State == ScheduledDelegate.RunState.Waiting) + selectionDebounce?.RunTask(); + } + private bool ensureGlobalBeatmapValid() { if (!this.IsCurrentScreen()) return false; - if (selectionDebounce?.State == ScheduledDelegate.RunState.Waiting) - selectionDebounce?.RunTask(); + finaliseBeatmapSelection(); // While filtering, let's not ever attempt to change selection. // This will be resolved after the filter completes, see `newItemsPresented`. @@ -483,8 +499,7 @@ namespace osu.Game.Screens.SelectV2 if (Beatmap.IsDefault || !validSelection) { validSelection = carousel.NextRandom(); - if (selectionDebounce?.State == ScheduledDelegate.RunState.Waiting) - selectionDebounce?.RunTask(); + finaliseBeatmapSelection(); } if (validSelection) From ee2a247c6681e7b82a7e30b50d72ad991b27bebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 25 Jun 2025 10:56:10 +0200 Subject: [PATCH 114/173] Add failing test case --- .../Visual/Ranking/TestSceneUserTagControl.cs | 55 ++++++++++++++++++- .../Ranking/UserTagControl_DrawableUserTag.cs | 2 +- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs index c546c9727c..b63f8ca31c 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -14,10 +16,12 @@ using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Ranking; +using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Ranking { - public partial class TestSceneUserTagControl : OsuTestScene + public partial class TestSceneUserTagControl : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); @@ -63,6 +67,8 @@ namespace osu.Game.Tests.Visual.Ranking beatmapSet.Beatmaps.Single().TopTags = [ new APIBeatmapTag { TagId = 3, VoteCount = 9 }, + new APIBeatmapTag { TagId = 2, VoteCount = 8 }, + new APIBeatmapTag { TagId = 0, VoteCount = 7 }, ]; Scheduler.AddDelayed(() => getBeatmapSetRequest.TriggerSuccess(beatmapSet), 500); return true; @@ -79,6 +85,11 @@ namespace osu.Game.Tests.Visual.Ranking return false; }; }); + } + + [Test] + public void TestRulesetSupport() + { AddStep("show for osu! beatmap", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); @@ -86,6 +97,7 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap.Value = working; recreateControl(); }); + AddStep("show for taiko beatmap", () => { var working = CreateWorkingBeatmap(new TaikoRuleset().RulesetInfo); @@ -95,6 +107,47 @@ namespace osu.Game.Tests.Visual.Ranking }); } + [Test] + public void TestTagsDoNotMoveUntilMouseMovesAway() + { + AddStep("show", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.BeatmapInfo.OnlineID = 42; + Beatmap.Value = working; + recreateControl(); + }); + AddUntilStep("wait for ready", () => getTagFlow().Count, () => Is.EqualTo(4)); + AddAssert("tag 2 is second", () => getTagFlow().GetLayoutPosition(getDrawableTagById(2)), () => Is.EqualTo(1)); + AddStep("vote for tag 2", () => + { + InputManager.MoveMouseTo(getDrawableTagById(2)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("tag 2 voted for", () => getDrawableTagById(2).UserTag.VoteCount.Value, () => Is.EqualTo(9)); + + AddStep("remove vote for tag 2", () => + { + InputManager.MoveMouseTo(getDrawableTagById(2)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("tag 2 not voted for", () => getDrawableTagById(2).UserTag.VoteCount.Value, () => Is.EqualTo(8)); + AddAssert("tag 2 is still second", () => getTagFlow().GetLayoutPosition(getDrawableTagById(2)), () => Is.EqualTo(1)); + + AddStep("vote for tag 2", () => + { + InputManager.MoveMouseTo(getDrawableTagById(2)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("tag 2 voted for", () => getDrawableTagById(2).UserTag.VoteCount.Value, () => Is.EqualTo(9)); + AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("tag 2 reordered to first", () => getTagFlow().GetLayoutPosition(getDrawableTagById(2)), () => Is.EqualTo(0)); + + FillFlowContainer getTagFlow() => this.ChildrenOfType>().Single(); + + UserTagControl.DrawableUserTag getDrawableTagById(long id) => getTagFlow().Single(t => t.UserTag.Id == id); + } + private void recreateControl() { Child = new PopoverContainer diff --git a/osu.Game/Screens/Ranking/UserTagControl_DrawableUserTag.cs b/osu.Game/Screens/Ranking/UserTagControl_DrawableUserTag.cs index e54d88bca2..ff3c0711c0 100644 --- a/osu.Game/Screens/Ranking/UserTagControl_DrawableUserTag.cs +++ b/osu.Game/Screens/Ranking/UserTagControl_DrawableUserTag.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Ranking { public partial class UserTagControl { - private partial class DrawableUserTag : OsuAnimatedButton + public partial class DrawableUserTag : OsuAnimatedButton { public readonly UserTag UserTag; From 7975120d5d67c488dc4a75966098616abe20405e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 25 Jun 2025 11:02:16 +0200 Subject: [PATCH 115/173] Fix user tags moving in the control after voting Closes https://github.com/ppy/osu/issues/33877. Most likely regressed when the user tags were changed such that the loading spinner that shows on adding/removing a vote was introdiced to every individual tag separately. This in turn means that the `LoadingLayer` responsible for showing the spinner also briefly consumes all input when visible, which also means that the control briefly becomes unhovered, breaking the logic. This probably doesn't work on mobile because mobile input sucks. On iOS simulator it looks somewhat fine in that the tags don't move until you touch the screen anywhere else which seems okay if that's what actually what happens on device as well. And if it isn't I'm not sure I can do anything sane about it anyway. --- osu.Game/Screens/Ranking/UserTagControl.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs index e323107783..5618dd2490 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -44,6 +45,8 @@ namespace osu.Game.Screens.Ranking private AddNewTagUserTag addNewTagUserTag = null!; + private InputManager inputManager = null!; + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -124,6 +127,8 @@ namespace osu.Game.Screens.Ranking updateTags(); displayedTags.BindCollectionChanged(displayTags, true); + + inputManager = GetContainingInputManager()!; } private void updateTags() @@ -251,7 +256,7 @@ namespace osu.Game.Screens.Ranking { base.Update(); - if (!layout.IsValid && !IsHovered) + if (!layout.IsValid && !Contains(inputManager.CurrentState.Mouse.Position)) { var sortedTags = new Dictionary( displayedTags.OrderByDescending(t => t.VoteCount.Value) From d42fcabcf41b06e7aa2287c6ec0e51dc39172375 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 18:27:49 +0900 Subject: [PATCH 116/173] Remove no longer used method --- osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs | 4 ---- osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs | 4 ---- osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs | 2 -- osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs | 4 ---- osu.Game/Rulesets/Mods/ModEasy.cs | 4 ---- osu.Game/Rulesets/Mods/ModHardRock.cs | 4 ---- 6 files changed, 22 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs index 72422a0ae8..71080e3d8e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs @@ -115,10 +115,6 @@ namespace osu.Game.Rulesets.Osu.Mods #region Reduce AR (IApplicableToDifficulty) - public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) - { - } - public void ApplyToDifficulty(BeatmapDifficulty difficulty) { // Decrease AR to increase preempt time diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs index e31a3dbdf0..2230763984 100644 --- a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs +++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs @@ -166,11 +166,7 @@ namespace osu.Game.Tests.Mods /// private BeatmapDifficulty applyDifficulty(BeatmapDifficulty difficulty) { - // ensure that ReadFromDifficulty doesn't pollute the values. var newDifficulty = difficulty.Clone(); - - testMod.ReadFromDifficulty(difficulty); - testMod.ApplyToDifficulty(newDifficulty); return newDifficulty; } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index ca6c4998d1..5cf503f21e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -119,7 +119,6 @@ namespace osu.Game.Tests.Visual.SongSelect { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); - difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.Difficulty); advancedStats.Mods.Value = new[] { difficultyAdjustMod }; }); @@ -140,7 +139,6 @@ namespace osu.Game.Tests.Visual.SongSelect var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); var originalDifficulty = advancedStats.BeatmapInfo.Difficulty; - difficultyAdjustMod.ReadFromDifficulty(originalDifficulty); difficultyAdjustMod.DrainRate.Value = originalDifficulty.DrainRate - 0.5f; difficultyAdjustMod.ApproachRate.Value = originalDifficulty.ApproachRate + 2.2f; advancedStats.Mods.Value = new[] { difficultyAdjustMod }; diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index 15ce583413..4fd9916b89 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -96,10 +96,6 @@ namespace osu.Game.Rulesets.Mods } } - public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) - { - } - public void ApplyToDifficulty(BeatmapDifficulty difficulty) => ApplySettings(difficulty); /// diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index b0ac0d5cce..3ee4d7846e 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -19,10 +19,6 @@ namespace osu.Game.Rulesets.Mods public override bool Ranked => UsesDefaultConfiguration; public override bool ValidForFreestyleAsRequiredMod => true; - public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty) - { - } - public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty) { const float ratio = 0.5f; diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index ce40e6e075..6149a9c712 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -22,10 +22,6 @@ namespace osu.Game.Rulesets.Mods protected const float ADJUST_RATIO = 1.4f; - public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) - { - } - public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty) { difficulty.DrainRate = Math.Min(difficulty.DrainRate * ADJUST_RATIO, 10.0f); From 0f078ee55040c3a55ed3dbbac61f4c6e0ddc0129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 18 Apr 2025 09:56:53 +0200 Subject: [PATCH 117/173] Apply flooring and half-millisecond-adjustments to hit windows This is a "two-birds-with-one-stone" change, which addresses both https://github.com/ppy/osu/issues/28744 and https://github.com/ppy/osu/issues/11311 simultaneously. - The replay stability issue caused by time instants being rounded to nearest integer is fixed by this, because flooring and subtracting/adding 0.5 from the hit window threshold makes it impossible for a judgement to switch to anything else after replay rounding is applied - all hit windows are always a full integer plus 0.5 milliseconds, which immunizes them to rounding-to-full-ms issues. - The direction of applying the 0.5 adjustment additionally fixes the disparity with stable - in osu! and taiko 0.5 is subtracted as hit window ranges in those rulesets are exclusive on stable, while in mania 0.5 is added, as the hit window ranges there are *inclusive* on stable. As should be obvious, this materially changes hit windows. To what degree this is a *significant* change is up for discussion; I would say "no" since hitting half a millisecond changes would require 2000fps input recording, and we're still timestamping inputs using the update thread's clock, that gives a 1ms resolution at best. In the worst case, in osu! and taiko, this can change a hit window range by 1.5ms (e.g. 300.9ms -> floored to 300ms -> 299.5ms after subtraction of the half). It's more than the best-case resolution of input timestamps, but not by much. Considering how cleanly this resolves the issues in question, I see it as an acceptable tradeoff. --- .../Scoring/ManiaHitWindows.cs | 12 +++--- .../TestSceneSliderLateHitJudgement.cs | 40 +++++++++---------- .../Scoring/OsuHitWindows.cs | 6 +-- .../Judgements/TestSceneHitJudgements.cs | 2 +- .../Scoring/TaikoHitWindows.cs | 6 +-- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs index c0ba03d8ed..96dbd957ae 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs @@ -53,12 +53,12 @@ namespace osu.Game.Rulesets.Mania.Scoring public override void SetDifficulty(double difficulty) { - perfect = IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range) * multiplier; - great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range) * multiplier; - good = IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range) * multiplier; - ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range) * multiplier; - meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range) * multiplier; - miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range) * multiplier; + perfect = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range) * multiplier) + 0.5; + great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range) * multiplier) + 0.5; + good = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range) * multiplier) + 0.5; + ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range) * multiplier) + 0.5; + meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range) * multiplier) + 0.5; + miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range) * multiplier) + 0.5; } public override double WindowFor(HitResult result) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index d089e924ca..3276516d0a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -52,8 +52,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 100, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 100, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 99, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 99, slider_end_position, OsuAction.LeftButton), }); assertHeadJudgement(HitResult.Ok); @@ -70,8 +70,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 100, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 100, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 99, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 99, slider_end_position, OsuAction.LeftButton), }, s => { s.SliderVelocityMultiplier = 2; @@ -91,8 +91,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_end_position, OsuAction.LeftButton), }, s => { s.TickDistanceMultiplier = 0.2f; @@ -116,8 +116,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_end_position, OsuAction.LeftButton), }, s => { s.SliderVelocityMultiplier = 2; @@ -165,8 +165,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.LINEAR, new[] @@ -195,8 +195,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.LINEAR, new[] @@ -224,8 +224,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] @@ -259,8 +259,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] @@ -289,8 +289,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] @@ -320,8 +320,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position - new Vector2(20), OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position - new Vector2(20), OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position - new Vector2(20), OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position - new Vector2(20), OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs index 154503c20d..a0f235c8c7 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -38,9 +38,9 @@ namespace osu.Game.Rulesets.Osu.Scoring public override void SetDifficulty(double difficulty) { - great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE); - ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE); - meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, MEH_WINDOW_RANGE); + great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE)) - 0.5; + ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE)) - 0.5; + meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, MEH_WINDOW_RANGE)) - 0.5; } public override double WindowFor(HitResult result) diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs index 7008d8d37a..c175e3342b 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs @@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements PerformTest(new List { new TaikoReplayFrame(0), - new TaikoReplayFrame(hit_time - hitWindows.WindowFor(HitResult.Great), TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time - (hitWindows.WindowFor(HitResult.Great) + 0.1), TaikoAction.LeftCentre), }, beatmap); AssertJudgementCount(1); diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs index 22d268de3b..f3a478f592 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs @@ -32,9 +32,9 @@ namespace osu.Game.Rulesets.Taiko.Scoring public override void SetDifficulty(double difficulty) { - great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE); - ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE); - miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, MISS_WINDOW_RANGE); + great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE)) - 0.5; + ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE)) - 0.5; + miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, MISS_WINDOW_RANGE)) - 0.5; } public override double WindowFor(HitResult result) From 89c48451cd969054af60cf588a20f36145309ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 25 Jun 2025 12:11:53 +0200 Subject: [PATCH 118/173] Uncomment & adjust relevant replay test cases The replay stability tests needed adjustments because hit windows have been materially changed with the previous commit. What matters in the replay stability tests is covering the time instants near the hit window edges and ensuring that re-encode doesn't mutate the resulting judgements, not what the particular numbers used are. --- .../TestSceneLegacyReplayPlayback.cs | 187 ++++++++++-------- .../TestSceneReplayStability.cs | 51 ++--- .../TestSceneLegacyReplayPlayback.cs | 1 - .../TestSceneReplayStability.cs | 55 +++--- .../TestSceneLegacyReplayPlayback.cs | 3 +- .../TestSceneReplayStability.cs | 43 ++-- 6 files changed, 169 insertions(+), 171 deletions(-) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs index 2a7f2dc7ea..2c17cd8015 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs @@ -19,7 +19,6 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Tests { - [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene { protected override Ruleset CreateRuleset() => new ManiaRuleset(); @@ -72,13 +71,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -137d, HitResult.Miss }, new object[] { 5f, -138d, HitResult.Miss }, new object[] { 5f, 111d, HitResult.Ok }, - new object[] { 5f, 112d, HitResult.Miss }, - new object[] { 5f, 113d, HitResult.Miss }, - new object[] { 5f, 114d, HitResult.Miss }, - new object[] { 5f, 135d, HitResult.Miss }, - new object[] { 5f, 136d, HitResult.Miss }, - new object[] { 5f, 137d, HitResult.Miss }, - new object[] { 5f, 138d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 112d, HitResult.Miss }, + // new object[] { 5f, 113d, HitResult.Miss }, + // new object[] { 5f, 114d, HitResult.Miss }, + // new object[] { 5f, 135d, HitResult.Miss }, + // new object[] { 5f, 136d, HitResult.Miss }, + // new object[] { 5f, 137d, HitResult.Miss }, + // new object[] { 5f, 138d, HitResult.Miss }, // OD = 9.3 test cases. // PERFECT hit window is [ -14ms, 14ms] @@ -99,13 +99,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 9.3f, 70d, HitResult.Ok }, new object[] { 9.3f, 71d, HitResult.Ok }, new object[] { 9.3f, 98d, HitResult.Ok }, - new object[] { 9.3f, 99d, HitResult.Miss }, - new object[] { 9.3f, 100d, HitResult.Miss }, - new object[] { 9.3f, 101d, HitResult.Miss }, - new object[] { 9.3f, 122d, HitResult.Miss }, - new object[] { 9.3f, 123d, HitResult.Miss }, - new object[] { 9.3f, 124d, HitResult.Miss }, - new object[] { 9.3f, 125d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 9.3f, 99d, HitResult.Miss }, + // new object[] { 9.3f, 100d, HitResult.Miss }, + // new object[] { 9.3f, 101d, HitResult.Miss }, + // new object[] { 9.3f, 122d, HitResult.Miss }, + // new object[] { 9.3f, 123d, HitResult.Miss }, + // new object[] { 9.3f, 124d, HitResult.Miss }, + // new object[] { 9.3f, 125d, HitResult.Miss }, new object[] { 9.3f, -98d, HitResult.Ok }, new object[] { 9.3f, -99d, HitResult.Ok }, new object[] { 9.3f, -100d, HitResult.Meh }, @@ -145,13 +146,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -137d, HitResult.Miss }, new object[] { 5f, -138d, HitResult.Miss }, new object[] { 5f, 111d, HitResult.Ok }, - new object[] { 5f, 112d, HitResult.Miss }, - new object[] { 5f, 113d, HitResult.Miss }, - new object[] { 5f, 114d, HitResult.Miss }, - new object[] { 5f, 135d, HitResult.Miss }, - new object[] { 5f, 136d, HitResult.Miss }, - new object[] { 5f, 137d, HitResult.Miss }, - new object[] { 5f, 138d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 112d, HitResult.Miss }, + // new object[] { 5f, 113d, HitResult.Miss }, + // new object[] { 5f, 114d, HitResult.Miss }, + // new object[] { 5f, 135d, HitResult.Miss }, + // new object[] { 5f, 136d, HitResult.Miss }, + // new object[] { 5f, 137d, HitResult.Miss }, + // new object[] { 5f, 138d, HitResult.Miss }, // OD = 9.3 test cases. // PERFECT hit window is [ -16ms, 16ms] @@ -172,13 +174,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 9.3f, 70d, HitResult.Ok }, new object[] { 9.3f, 71d, HitResult.Ok }, new object[] { 9.3f, 98d, HitResult.Ok }, - new object[] { 9.3f, 99d, HitResult.Miss }, - new object[] { 9.3f, 100d, HitResult.Miss }, - new object[] { 9.3f, 101d, HitResult.Miss }, - new object[] { 9.3f, 122d, HitResult.Miss }, - new object[] { 9.3f, 123d, HitResult.Miss }, - new object[] { 9.3f, 124d, HitResult.Miss }, - new object[] { 9.3f, 125d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 9.3f, 99d, HitResult.Miss }, + // new object[] { 9.3f, 100d, HitResult.Miss }, + // new object[] { 9.3f, 101d, HitResult.Miss }, + // new object[] { 9.3f, 122d, HitResult.Miss }, + // new object[] { 9.3f, 123d, HitResult.Miss }, + // new object[] { 9.3f, 124d, HitResult.Miss }, + // new object[] { 9.3f, 125d, HitResult.Miss }, new object[] { 9.3f, -98d, HitResult.Ok }, new object[] { 9.3f, -99d, HitResult.Ok }, new object[] { 9.3f, -100d, HitResult.Meh }, @@ -207,13 +210,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 3.1f, 88d, HitResult.Ok }, new object[] { 3.1f, 89d, HitResult.Ok }, new object[] { 3.1f, 116d, HitResult.Ok }, - new object[] { 3.1f, 117d, HitResult.Miss }, - new object[] { 3.1f, 118d, HitResult.Miss }, - new object[] { 3.1f, 119d, HitResult.Miss }, - new object[] { 3.1f, 140d, HitResult.Miss }, - new object[] { 3.1f, 141d, HitResult.Miss }, - new object[] { 3.1f, 142d, HitResult.Miss }, - new object[] { 3.1f, 143d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 3.1f, 117d, HitResult.Miss }, + // new object[] { 3.1f, 118d, HitResult.Miss }, + // new object[] { 3.1f, 119d, HitResult.Miss }, + // new object[] { 3.1f, 140d, HitResult.Miss }, + // new object[] { 3.1f, 141d, HitResult.Miss }, + // new object[] { 3.1f, 142d, HitResult.Miss }, + // new object[] { 3.1f, 143d, HitResult.Miss }, new object[] { 3.1f, -116d, HitResult.Ok }, new object[] { 3.1f, -117d, HitResult.Ok }, new object[] { 3.1f, -118d, HitResult.Meh }, @@ -253,13 +257,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -122d, HitResult.Miss }, new object[] { 5f, -123d, HitResult.Miss }, new object[] { 5f, 96d, HitResult.Ok }, - new object[] { 5f, 97d, HitResult.Miss }, - new object[] { 5f, 98d, HitResult.Miss }, - new object[] { 5f, 99d, HitResult.Miss }, - new object[] { 5f, 120d, HitResult.Miss }, - new object[] { 5f, 121d, HitResult.Miss }, - new object[] { 5f, 122d, HitResult.Miss }, - new object[] { 5f, 123d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 97d, HitResult.Miss }, + // new object[] { 5f, 98d, HitResult.Miss }, + // new object[] { 5f, 99d, HitResult.Miss }, + // new object[] { 5f, 120d, HitResult.Miss }, + // new object[] { 5f, 121d, HitResult.Miss }, + // new object[] { 5f, 122d, HitResult.Miss }, + // new object[] { 5f, 123d, HitResult.Miss }, // OD = 3.1 test cases. // PERFECT hit window is [ -16ms, 16ms] @@ -280,13 +285,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 3.1f, 78d, HitResult.Ok }, new object[] { 3.1f, 79d, HitResult.Ok }, new object[] { 3.1f, 96d, HitResult.Ok }, - new object[] { 3.1f, 97d, HitResult.Miss }, - new object[] { 3.1f, 98d, HitResult.Miss }, - new object[] { 3.1f, 99d, HitResult.Miss }, - new object[] { 3.1f, 120d, HitResult.Miss }, - new object[] { 3.1f, 121d, HitResult.Miss }, - new object[] { 3.1f, 122d, HitResult.Miss }, - new object[] { 3.1f, 123d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 3.1f, 97d, HitResult.Miss }, + // new object[] { 3.1f, 98d, HitResult.Miss }, + // new object[] { 3.1f, 99d, HitResult.Miss }, + // new object[] { 3.1f, 120d, HitResult.Miss }, + // new object[] { 3.1f, 121d, HitResult.Miss }, + // new object[] { 3.1f, 122d, HitResult.Miss }, + // new object[] { 3.1f, 123d, HitResult.Miss }, new object[] { 3.1f, -96d, HitResult.Ok }, new object[] { 3.1f, -97d, HitResult.Ok }, new object[] { 3.1f, -98d, HitResult.Meh }, @@ -327,13 +333,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -98d, HitResult.Miss }, new object[] { 5f, -99d, HitResult.Miss }, new object[] { 5f, 79d, HitResult.Ok }, - new object[] { 5f, 80d, HitResult.Miss }, - new object[] { 5f, 81d, HitResult.Miss }, - new object[] { 5f, 82d, HitResult.Miss }, - new object[] { 5f, 96d, HitResult.Miss }, - new object[] { 5f, 97d, HitResult.Miss }, - new object[] { 5f, 98d, HitResult.Miss }, - new object[] { 5f, 99d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 80d, HitResult.Miss }, + // new object[] { 5f, 81d, HitResult.Miss }, + // new object[] { 5f, 82d, HitResult.Miss }, + // new object[] { 5f, 96d, HitResult.Miss }, + // new object[] { 5f, 97d, HitResult.Miss }, + // new object[] { 5f, 98d, HitResult.Miss }, + // new object[] { 5f, 99d, HitResult.Miss }, // OD = 9.3 test cases. // This leads to "effective" OD of 13.02. @@ -356,13 +363,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 9.3f, 50d, HitResult.Ok }, new object[] { 9.3f, 51d, HitResult.Ok }, new object[] { 9.3f, 69d, HitResult.Ok }, - new object[] { 9.3f, 70d, HitResult.Miss }, - new object[] { 9.3f, 71d, HitResult.Miss }, - new object[] { 9.3f, 72d, HitResult.Miss }, - new object[] { 9.3f, 86d, HitResult.Miss }, - new object[] { 9.3f, 87d, HitResult.Miss }, - new object[] { 9.3f, 88d, HitResult.Miss }, - new object[] { 9.3f, 89d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 9.3f, 70d, HitResult.Miss }, + // new object[] { 9.3f, 71d, HitResult.Miss }, + // new object[] { 9.3f, 72d, HitResult.Miss }, + // new object[] { 9.3f, 86d, HitResult.Miss }, + // new object[] { 9.3f, 87d, HitResult.Miss }, + // new object[] { 9.3f, 88d, HitResult.Miss }, + // new object[] { 9.3f, 89d, HitResult.Miss }, new object[] { 9.3f, -69d, HitResult.Ok }, new object[] { 9.3f, -70d, HitResult.Ok }, new object[] { 9.3f, -71d, HitResult.Meh }, @@ -402,13 +410,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -191d, HitResult.Miss }, new object[] { 5f, -192d, HitResult.Miss }, new object[] { 5f, 155d, HitResult.Ok }, - new object[] { 5f, 156d, HitResult.Miss }, - new object[] { 5f, 157d, HitResult.Miss }, - new object[] { 5f, 158d, HitResult.Miss }, - new object[] { 5f, 189d, HitResult.Miss }, - new object[] { 5f, 190d, HitResult.Miss }, - new object[] { 5f, 191d, HitResult.Miss }, - new object[] { 5f, 192d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 156d, HitResult.Miss }, + // new object[] { 5f, 157d, HitResult.Miss }, + // new object[] { 5f, 158d, HitResult.Miss }, + // new object[] { 5f, 189d, HitResult.Miss }, + // new object[] { 5f, 190d, HitResult.Miss }, + // new object[] { 5f, 191d, HitResult.Miss }, + // new object[] { 5f, 192d, HitResult.Miss }, }; private static readonly object[][] score_v1_non_convert_double_time_test_cases = @@ -440,13 +449,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -205d, HitResult.Miss }, new object[] { 5f, -206d, HitResult.Miss }, new object[] { 5f, 167d, HitResult.Ok }, - new object[] { 5f, 168d, HitResult.Miss }, - new object[] { 5f, 169d, HitResult.Miss }, - new object[] { 5f, 170d, HitResult.Miss }, - new object[] { 5f, 203d, HitResult.Miss }, - new object[] { 5f, 204d, HitResult.Miss }, - new object[] { 5f, 205d, HitResult.Miss }, - new object[] { 5f, 206d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 168d, HitResult.Miss }, + // new object[] { 5f, 169d, HitResult.Miss }, + // new object[] { 5f, 170d, HitResult.Miss }, + // new object[] { 5f, 203d, HitResult.Miss }, + // new object[] { 5f, 204d, HitResult.Miss }, + // new object[] { 5f, 205d, HitResult.Miss }, + // new object[] { 5f, 206d, HitResult.Miss }, }; private static readonly object[][] score_v1_non_convert_half_time_test_cases = @@ -478,13 +488,14 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 5f, -103d, HitResult.Miss }, new object[] { 5f, -104d, HitResult.Miss }, new object[] { 5f, 83d, HitResult.Ok }, - new object[] { 5f, 84d, HitResult.Miss }, - new object[] { 5f, 85d, HitResult.Miss }, - new object[] { 5f, 86d, HitResult.Miss }, - new object[] { 5f, 101d, HitResult.Miss }, - new object[] { 5f, 102d, HitResult.Miss }, - new object[] { 5f, 103d, HitResult.Miss }, - new object[] { 5f, 104d, HitResult.Miss }, + // coverage of broken "can't hit meh late" behaviour, which is intentionally not being reproduced + // new object[] { 5f, 84d, HitResult.Miss }, + // new object[] { 5f, 85d, HitResult.Miss }, + // new object[] { 5f, 86d, HitResult.Miss }, + // new object[] { 5f, 101d, HitResult.Miss }, + // new object[] { 5f, 102d, HitResult.Miss }, + // new object[] { 5f, 103d, HitResult.Miss }, + // new object[] { 5f, 104d, HitResult.Miss }, }; private const double note_time = 300; @@ -517,6 +528,7 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV2 single note @ OD{overallDifficulty}", beatmap, $@"SV2 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [Ignore("Tests expected to fail until stable's detailed treatment of hit windows in mania is reproduced.")] [TestCaseSource(nameof(score_v1_non_convert_test_cases))] public void TestHitWindowTreatmentWithScoreV1NonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { @@ -544,6 +556,7 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1 single note @ OD{overallDifficulty}", beatmap, $@"SV1 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [Ignore("Tests expected to fail until stable's detailed treatment of hit windows in mania is reproduced.")] [TestCaseSource(nameof(score_v1_convert_test_cases))] public void TestHitWindowTreatmentWithScoreV1Convert(float overallDifficulty, double hitOffset, HitResult expectedResult) { @@ -572,6 +585,7 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [Ignore("Tests expected to fail until stable's detailed treatment of hit windows in mania is reproduced.")] [TestCaseSource(nameof(score_v1_non_convert_hard_rock_test_cases))] public void TestHitWindowTreatmentWithScoreV1AndHardRockNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { @@ -600,6 +614,7 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1+HR single note @ OD{overallDifficulty}", beatmap, $@"SV1+HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [Ignore("Tests expected to fail until stable's detailed treatment of hit windows in mania is reproduced.")] [TestCaseSource(nameof(score_v1_non_convert_easy_test_cases))] public void TestHitWindowTreatmentWithScoreV1AndEasyNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { @@ -628,6 +643,7 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1+EZ single note @ OD{overallDifficulty}", beatmap, $@"SV1+EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [Ignore("Tests expected to fail until stable's detailed treatment of hit windows in mania is reproduced.")] [TestCaseSource(nameof(score_v1_non_convert_double_time_test_cases))] public void TestHitWindowTreatmentWithScoreV1AndDoubleTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { @@ -656,6 +672,7 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1+DT single note @ OD{overallDifficulty}", beatmap, $@"SV1+DT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [Ignore("Tests expected to fail until stable's detailed treatment of hit windows in mania is reproduced.")] [TestCaseSource(nameof(score_v1_non_convert_half_time_test_cases))] public void TestHitWindowTreatmentWithScoreV1AndHalfTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs index 64496d7628..a8160d3373 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs @@ -12,7 +12,6 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests { - [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] public partial class TestSceneReplayStability : ReplayStabilityTestScene { private static readonly object[][] test_cases = @@ -22,87 +21,79 @@ namespace osu.Game.Rulesets.Mania.Tests // while round brackets `()` represent *open* or *exclusive* bounds. // OD = 5 test cases. - // PERFECT hit window is [ -19.4ms, 19.4ms] - // GREAT hit window is [ -49.0ms, 49.0ms] - // GOOD hit window is [ -82.0ms, 82.0ms] - // OK hit window is [-112.0ms, 112.0ms] - // MEH hit window is [-136.0ms, 136.0ms] - // MISS hit window is [-173.0ms, 173.0ms] + // PERFECT hit window is [ -19.5ms, 19.5ms] + // GREAT hit window is [ -49.5ms, 49.5ms] + // GOOD hit window is [ -82.5ms, 82.5ms] + // OK hit window is [-112.5ms, 112.5ms] + // MEH hit window is [-136.5ms, 136.5ms] + // MISS hit window is [-173.5ms, 173.5ms] new object[] { 5f, -19d, HitResult.Perfect }, new object[] { 5f, -19.2d, HitResult.Perfect }, - new object[] { 5f, -19.38d, HitResult.Perfect }, - // new object[] { 5f, -19.4d, HitResult.Perfect }, <- in theory this should work, in practice it does not (fails even before encode & rounding due to floating point precision issues) - new object[] { 5f, -19.44d, HitResult.Great }, new object[] { 5f, -19.7d, HitResult.Great }, new object[] { 5f, -20d, HitResult.Great }, new object[] { 5f, -48d, HitResult.Great }, new object[] { 5f, -48.4d, HitResult.Great }, new object[] { 5f, -48.7d, HitResult.Great }, new object[] { 5f, -49d, HitResult.Great }, - new object[] { 5f, -49.2d, HitResult.Good }, + new object[] { 5f, -49.2d, HitResult.Great }, new object[] { 5f, -49.7d, HitResult.Good }, new object[] { 5f, -50d, HitResult.Good }, new object[] { 5f, -81d, HitResult.Good }, new object[] { 5f, -81.2d, HitResult.Good }, new object[] { 5f, -81.7d, HitResult.Good }, new object[] { 5f, -82d, HitResult.Good }, - new object[] { 5f, -82.2d, HitResult.Ok }, + new object[] { 5f, -82.2d, HitResult.Good }, new object[] { 5f, -82.7d, HitResult.Ok }, new object[] { 5f, -83d, HitResult.Ok }, new object[] { 5f, -111d, HitResult.Ok }, new object[] { 5f, -111.2d, HitResult.Ok }, new object[] { 5f, -111.7d, HitResult.Ok }, new object[] { 5f, -112d, HitResult.Ok }, - new object[] { 5f, -112.2d, HitResult.Meh }, + new object[] { 5f, -112.2d, HitResult.Ok }, new object[] { 5f, -112.7d, HitResult.Meh }, new object[] { 5f, -113d, HitResult.Meh }, new object[] { 5f, -135d, HitResult.Meh }, new object[] { 5f, -135.2d, HitResult.Meh }, new object[] { 5f, -135.8d, HitResult.Meh }, new object[] { 5f, -136d, HitResult.Meh }, - new object[] { 5f, -136.2d, HitResult.Miss }, + new object[] { 5f, -136.2d, HitResult.Meh }, new object[] { 5f, -136.7d, HitResult.Miss }, new object[] { 5f, -137d, HitResult.Miss }, // OD = 9.3 test cases. - // PERFECT hit window is [ -14.67ms, 14.67ms] - // GREAT hit window is [ -36.10ms, 36.10ms] - // GOOD hit window is [ -69.10ms, 69.10ms] - // OK hit window is [ -99.10ms, 99.10ms] - // MEH hit window is [-123.10ms, 123.10ms] - // MISS hit window is [-160.10ms, 160.10ms] + // PERFECT hit window is [ -14.5ms, 14.5ms] + // GREAT hit window is [ -36.5ms, 36.5ms] + // GOOD hit window is [ -69.5ms, 69.5ms] + // OK hit window is [ -99.5ms, 99.5ms] + // MEH hit window is [-123.5ms, 123.5ms] + // MISS hit window is [-160.5ms, 160.5ms] new object[] { 9.3f, 14d, HitResult.Perfect }, new object[] { 9.3f, 14.2d, HitResult.Perfect }, - new object[] { 9.3f, 14.6d, HitResult.Perfect }, - // new object[] { 9.3f, 14.67d, HitResult.Perfect }, <- in theory this should work, in practice it does not (fails even before encode & rounding due to floating point precision issues) new object[] { 9.3f, 14.7d, HitResult.Great }, new object[] { 9.3f, 15d, HitResult.Great }, new object[] { 9.3f, 35d, HitResult.Great }, new object[] { 9.3f, 35.3d, HitResult.Great }, new object[] { 9.3f, 35.8d, HitResult.Great }, - new object[] { 9.3f, 36.05d, HitResult.Great }, - new object[] { 9.3f, 36.3d, HitResult.Good }, + new object[] { 9.3f, 36.3d, HitResult.Great }, new object[] { 9.3f, 36.7d, HitResult.Good }, new object[] { 9.3f, 37d, HitResult.Good }, new object[] { 9.3f, 68d, HitResult.Good }, new object[] { 9.3f, 68.4d, HitResult.Good }, new object[] { 9.3f, 68.9d, HitResult.Good }, - new object[] { 9.3f, 69.07d, HitResult.Good }, - new object[] { 9.3f, 69.25d, HitResult.Ok }, + new object[] { 9.3f, 69.25d, HitResult.Good }, new object[] { 9.3f, 69.85d, HitResult.Ok }, new object[] { 9.3f, 70d, HitResult.Ok }, new object[] { 9.3f, 98d, HitResult.Ok }, new object[] { 9.3f, 98.3d, HitResult.Ok }, new object[] { 9.3f, 98.6d, HitResult.Ok }, new object[] { 9.3f, 99d, HitResult.Ok }, - new object[] { 9.3f, 99.3d, HitResult.Meh }, + new object[] { 9.3f, 99.3d, HitResult.Ok }, new object[] { 9.3f, 99.7d, HitResult.Meh }, new object[] { 9.3f, 100d, HitResult.Meh }, new object[] { 9.3f, 122d, HitResult.Meh }, new object[] { 9.3f, 122.34d, HitResult.Meh }, new object[] { 9.3f, 122.57d, HitResult.Meh }, - new object[] { 9.3f, 123.04d, HitResult.Meh }, - new object[] { 9.3f, 123.45d, HitResult.Miss }, + new object[] { 9.3f, 123.45d, HitResult.Meh }, new object[] { 9.3f, 123.95d, HitResult.Miss }, new object[] { 9.3f, 124d, HitResult.Miss }, }; @@ -110,7 +101,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCaseSource(nameof(test_cases))] public void TestHitWindowStability(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 100; + const double note_time = 300; var beatmap = new ManiaBeatmap(new StageDefinition(1)) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs index 379699b276..404ca0c79e 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs @@ -17,7 +17,6 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene { protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs index 2303b17d96..320fdcff2c 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs @@ -13,7 +13,6 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { - [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] public partial class TestSceneReplayStability : ReplayStabilityTestScene { private static readonly object[][] test_cases = @@ -23,53 +22,49 @@ namespace osu.Game.Rulesets.Osu.Tests // while round brackets `()` represent *open* or *exclusive* bounds. // OD = 5 test cases. - // GREAT hit window is [ -50ms, 50ms] - // OK hit window is [-100ms, 100ms] - // MEH hit window is [-150ms, 150ms] - // MISS hit window is [-400ms, 400ms] + // GREAT hit window is [ -49.5ms, 49.5ms] + // OK hit window is [ -99.5ms, 99.5ms] + // MEH hit window is [-149.5ms, 149.5ms] new object[] { 5f, 49d, HitResult.Great }, new object[] { 5f, 49.2d, HitResult.Great }, - new object[] { 5f, 49.7d, HitResult.Great }, - new object[] { 5f, 50d, HitResult.Great }, + new object[] { 5f, 49.7d, HitResult.Ok }, + new object[] { 5f, 50d, HitResult.Ok }, new object[] { 5f, 50.4d, HitResult.Ok }, new object[] { 5f, 50.9d, HitResult.Ok }, new object[] { 5f, 51d, HitResult.Ok }, new object[] { 5f, 99d, HitResult.Ok }, new object[] { 5f, 99.2d, HitResult.Ok }, - new object[] { 5f, 99.7d, HitResult.Ok }, - new object[] { 5f, 100d, HitResult.Ok }, + new object[] { 5f, 99.7d, HitResult.Meh }, + new object[] { 5f, 100d, HitResult.Meh }, new object[] { 5f, 100.4d, HitResult.Meh }, new object[] { 5f, 100.9d, HitResult.Meh }, new object[] { 5f, 101d, HitResult.Meh }, new object[] { 5f, 149d, HitResult.Meh }, new object[] { 5f, 149.2d, HitResult.Meh }, - new object[] { 5f, 149.7d, HitResult.Meh }, - new object[] { 5f, 150d, HitResult.Meh }, + new object[] { 5f, 149.7d, HitResult.Miss }, + new object[] { 5f, 150d, HitResult.Miss }, new object[] { 5f, 150.4d, HitResult.Miss }, new object[] { 5f, 150.9d, HitResult.Miss }, new object[] { 5f, 151d, HitResult.Miss }, // OD = 5.7 test cases. - // GREAT hit window is [ -45.8ms, 45.8ms] - // OK hit window is [ -94.4ms, 94.4ms] - // MEH hit window is [-143.0ms, 143.0ms] - // MISS hit window is [-400.0ms, 400.0ms] - new object[] { 5.7f, 45d, HitResult.Great }, - new object[] { 5.7f, 45.2d, HitResult.Great }, - new object[] { 5.7f, 45.8d, HitResult.Great }, - new object[] { 5.7f, 45.9d, HitResult.Ok }, - new object[] { 5.7f, 46d, HitResult.Ok }, - new object[] { 5.7f, 46.4d, HitResult.Ok }, - new object[] { 5.7f, 94d, HitResult.Ok }, - new object[] { 5.7f, 94.2d, HitResult.Ok }, - new object[] { 5.7f, 94.4d, HitResult.Ok }, - new object[] { 5.7f, 94.48d, HitResult.Ok }, - new object[] { 5.7f, 94.9d, HitResult.Meh }, - new object[] { 5.7f, 95d, HitResult.Meh }, - new object[] { 5.7f, 95.4d, HitResult.Meh }, + // GREAT hit window is [ -44.5ms, 44.5ms] + // OK hit window is [ -93.5ms, 93.5ms] + // MEH hit window is [-142.5ms, 142.5ms] + new object[] { 5.7f, 44d, HitResult.Great }, + new object[] { 5.7f, 44.2d, HitResult.Great }, + new object[] { 5.7f, 44.8d, HitResult.Ok }, + new object[] { 5.7f, 45d, HitResult.Ok }, + new object[] { 5.7f, 45.4d, HitResult.Ok }, + new object[] { 5.7f, 93d, HitResult.Ok }, + new object[] { 5.7f, 93.4d, HitResult.Ok }, + new object[] { 5.7f, 93.9d, HitResult.Meh }, + new object[] { 5.7f, 94d, HitResult.Meh }, + new object[] { 5.7f, 94.4d, HitResult.Meh }, new object[] { 5.7f, 142d, HitResult.Meh }, - new object[] { 5.7f, 142.7d, HitResult.Meh }, - new object[] { 5.7f, 143d, HitResult.Meh }, + new object[] { 5.7f, 142.2d, HitResult.Meh }, + new object[] { 5.7f, 142.7d, HitResult.Miss }, + new object[] { 5.7f, 143d, HitResult.Miss }, new object[] { 5.7f, 143.4d, HitResult.Miss }, new object[] { 5.7f, 143.9d, HitResult.Miss }, new object[] { 5.7f, 144d, HitResult.Miss }, diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs index 5e71f974d8..40a426b360 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs @@ -15,7 +15,6 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene { protected override string? ExportLocation => null; @@ -177,7 +176,7 @@ namespace osu.Game.Rulesets.Taiko.Tests ScoreInfo = new ScoreInfo { Ruleset = CreateRuleset().RulesetInfo, - Mods = [new TaikoModHardRock()] + Mods = [new TaikoModEasy()] } }; diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs index 62bbebcf0b..c61ae8ecc7 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs @@ -12,7 +12,6 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] public partial class TestSceneReplayStability : ReplayStabilityTestScene { private static readonly object[][] test_cases = @@ -22,40 +21,38 @@ namespace osu.Game.Rulesets.Taiko.Tests // while round brackets `()` represent *open* or *exclusive* bounds. // OD = 5 test cases. - // GREAT hit window is [-35ms, 35ms] - // OK hit window is [-80ms, 80ms] - // MISS hit window is [-95ms, 95ms] + // GREAT hit window is [-34.5ms, 34.5ms] + // OK hit window is [-79.5ms, 79.5ms] + // MISS hit window is [-94.5ms, 94.5ms] new object[] { 5f, -34d, HitResult.Great }, new object[] { 5f, -34.2d, HitResult.Great }, - new object[] { 5f, -34.7d, HitResult.Great }, - new object[] { 5f, -35d, HitResult.Great }, + new object[] { 5f, -34.7d, HitResult.Ok }, + new object[] { 5f, -35d, HitResult.Ok }, new object[] { 5f, -35.2d, HitResult.Ok }, new object[] { 5f, -35.8d, HitResult.Ok }, new object[] { 5f, -36d, HitResult.Ok }, new object[] { 5f, -79d, HitResult.Ok }, new object[] { 5f, -79.3d, HitResult.Ok }, - new object[] { 5f, -79.7d, HitResult.Ok }, - new object[] { 5f, -80d, HitResult.Ok }, + new object[] { 5f, -79.7d, HitResult.Miss }, + new object[] { 5f, -80d, HitResult.Miss }, new object[] { 5f, -80.2d, HitResult.Miss }, new object[] { 5f, -80.8d, HitResult.Miss }, new object[] { 5f, -81d, HitResult.Miss }, // OD = 7.8 test cases. - // GREAT hit window is [-26.6ms, 26.6ms] - // OK hit window is [-63.2ms, 63.2ms] - // MISS hit window is [-81.0ms, 81.0ms] - new object[] { 7.8f, -26d, HitResult.Great }, - new object[] { 7.8f, -26.4d, HitResult.Great }, - new object[] { 7.8f, -26.59d, HitResult.Great }, - new object[] { 7.8f, -26.8d, HitResult.Ok }, - new object[] { 7.8f, -27d, HitResult.Ok }, - new object[] { 7.8f, -27.1d, HitResult.Ok }, - new object[] { 7.8f, -63d, HitResult.Ok }, - new object[] { 7.8f, -63.18d, HitResult.Ok }, - new object[] { 7.8f, -63.4d, HitResult.Ok }, - new object[] { 7.8f, -63.7d, HitResult.Miss }, - new object[] { 7.8f, -64d, HitResult.Miss }, - new object[] { 7.8f, -64.2d, HitResult.Miss }, + // GREAT hit window is [-25.5ms, 25.5ms] + // OK hit window is [-62.5ms, 62.5ms] + // MISS hit window is [-80.5ms, 80.5ms] + new object[] { 7.8f, -25d, HitResult.Great }, + new object[] { 7.8f, -25.4d, HitResult.Great }, + new object[] { 7.8f, -25.8d, HitResult.Ok }, + new object[] { 7.8f, -26d, HitResult.Ok }, + new object[] { 7.8f, -26.1d, HitResult.Ok }, + new object[] { 7.8f, -62d, HitResult.Ok }, + new object[] { 7.8f, -62.4d, HitResult.Ok }, + new object[] { 7.8f, -62.7d, HitResult.Miss }, + new object[] { 7.8f, -63d, HitResult.Miss }, + new object[] { 7.8f, -63.2d, HitResult.Miss }, }; [TestCaseSource(nameof(test_cases))] From 5d69af55f07cee0699bb0d8590d8da116607b231 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 21:16:42 +0900 Subject: [PATCH 119/173] Add test hitting next circle during tail window --- .../TestSceneSliderInput.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 286e4bd775..362a86065d 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -484,6 +484,50 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("no miss judgements recorded", () => judgementResults.All(r => r.Type.IsHit())); } + /// + /// Sliders are common to by 1/2 or 1/4 beat length in order to place the circle on the next beat. + /// This tests a user pressing the next circle in the window between the last tick and the end of the slider (). + /// + [Test] + public void TestHitNextCircleDuringTailLeniency() + { + const double bpm = 240; + const double beat_length = 60000 / bpm; + const double slider_start = time_slider_start; + const double slider_end = slider_start + beat_length; + const double last_tick_time = slider_end + SliderEventGenerator.TAIL_LENIENCY; + const double next_circle_time = slider_end + beat_length / 4; + + performTest(new List + { + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + // This frame is weird because the "up" frame can be skipped if the current time passes it (and is thus no longer in an important section). + // So the idea is to instead generate another important frame at the intended time without yet hitting the next circle. + new OsuReplayFrame { Position = new Vector2(100, 0), Actions = { OsuAction.RightButton }, Time = last_tick_time + 5 }, + new OsuReplayFrame { Position = new Vector2(140, 0), Actions = { OsuAction.LeftButton }, Time = last_tick_time + 20 }, + }, + [ + new Slider + { + StartTime = slider_start, + Position = new Vector2(0, 0), + TickDistanceMultiplier = 10, // no ticks + Path = new SliderPath(PathType.PERFECT_CURVE, new[] + { + Vector2.Zero, + new Vector2(100, 0), + }, 100), + }, + new HitCircle + { + StartTime = next_circle_time, + Position = new Vector2(140, 0) + } + ], bpm: bpm); + + AddAssert("all judgements are hit", () => judgementResults.All(j => j.Type.IsHit())); + } + private void assertAllMaxJudgements() { AddAssert("All judgements max", () => @@ -522,6 +566,11 @@ namespace osu.Game.Rulesets.Osu.Tests }, slider_path_length), }; + performTest(frames, [slider], bpm, tickRate); + } + + private void performTest(List frames, List objects, double? bpm = null, int? tickRate = null) + { AddStep("load player", () => { var cpi = new ControlPointInfo(); @@ -531,7 +580,7 @@ namespace osu.Game.Rulesets.Osu.Tests Beatmap.Value = CreateWorkingBeatmap(new Beatmap { - HitObjects = { slider }, + HitObjects = objects, BeatmapInfo = { Difficulty = new BeatmapDifficulty From 5dc08d4351c5348f79cc5afa599aad0b93c3d860 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 25 Jun 2025 21:50:04 +0900 Subject: [PATCH 120/173] Simplify important frame management --- osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 362a86065d..0842f8f14f 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -501,10 +501,7 @@ namespace osu.Game.Rulesets.Osu.Tests performTest(new List { new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton }, Time = time_slider_start }, - // This frame is weird because the "up" frame can be skipped if the current time passes it (and is thus no longer in an important section). - // So the idea is to instead generate another important frame at the intended time without yet hitting the next circle. - new OsuReplayFrame { Position = new Vector2(100, 0), Actions = { OsuAction.RightButton }, Time = last_tick_time + 5 }, - new OsuReplayFrame { Position = new Vector2(140, 0), Actions = { OsuAction.LeftButton }, Time = last_tick_time + 20 }, + new OsuReplayFrame { Position = new Vector2(140, 0), Actions = { OsuAction.RightButton }, Time = last_tick_time + 20 }, }, [ new Slider From 75a3cdcfe2ed730be99f576275d42541cb60206e Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 26 Jun 2025 02:56:43 +0300 Subject: [PATCH 121/173] Move room URL formatting to extension method --- osu.Game/Online/Rooms/RoomExtensions.cs | 21 +++++++++++++++++++ .../OnlinePlay/Lounge/Components/RoomPanel.cs | 10 ++++----- 2 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 osu.Game/Online/Rooms/RoomExtensions.cs diff --git a/osu.Game/Online/Rooms/RoomExtensions.cs b/osu.Game/Online/Rooms/RoomExtensions.cs new file mode 100644 index 0000000000..b7348e8997 --- /dev/null +++ b/osu.Game/Online/Rooms/RoomExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Rooms +{ + public static class RoomExtensions + { + /// + /// Get the room page URL, or null if unavailable. + /// + public static string? GetOnlineURL(this Room room, IAPIProvider api) + { + if (!room.RoomID.HasValue) + return null; + + return $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{room.RoomID.Value}"; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index 7a4279ef98..ae593cd3cb 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -380,11 +380,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { var items = new List(); - if (Room.RoomID.HasValue) + string? url = Room.GetOnlineURL(api); + + if (url != null) { items.AddRange([ - new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(getRoomUrl())), - new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(getRoomUrl())) + new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(url)), + new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(url)) ]); } @@ -392,8 +394,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - private string? getRoomUrl() => !Room.RoomID.HasValue ? null : $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{Room.RoomID.Value}"; - protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite(); protected virtual IEnumerable CreateBottomDetails() From 8b67747735347a21867ecac7c9e5fe8d7459fb8b Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 26 Jun 2025 02:57:18 +0300 Subject: [PATCH 122/173] Simplify code and fix link not updating on changes to room ID --- .../OnlinePlay/Lounge/Components/RoomPanel.cs | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index ae593cd3cb..b9f84b4fa4 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -207,7 +207,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - roomName = new RoomNameLine(getRoomUrl(), ShowExternalLink), + roomName = new RoomNameLine(), new RoomStatusText(Room) { Beatmap = { BindTarget = currentBeatmap } @@ -278,6 +278,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components wrapper.FadeInFromZero(200); + updateRoomID(); updateRoomName(); updateRoomCategory(); updateRoomType(); @@ -291,6 +292,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { switch (e.PropertyName) { + case nameof(Room.RoomID): + updateRoomID(); + break; + case nameof(Room.Name): updateRoomName(); break; @@ -334,6 +339,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components }), cancellationSource.Token); } + private void updateRoomID() + { + if (roomName != null && ShowExternalLink) + roomName.Link = Room.GetOnlineURL(api); + } + private void updateRoomName() { if (roomName != null) @@ -558,11 +569,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public partial class RoomNameLine : FillFlowContainer { - private readonly string? roomUrl; - private readonly bool showExternalLink; - private TruncatingSpriteText spriteText = null!; - private ExternalLinkButton link = null!; + private ExternalLinkButton linkButton = null!; public LocalisableString Text { @@ -570,10 +578,16 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components set => spriteText.Text = value; } - public RoomNameLine(string? roomUrl, bool showExternalLink) + private string? link; + + public string? Link { - this.roomUrl = roomUrl; - this.showExternalLink = showExternalLink; + get => link; + set + { + link = value; + updateLink(); + } } [BackgroundDependencyLoader] @@ -591,20 +605,31 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Origin = Anchor.BottomLeft, Font = OsuFont.GetFont(size: 28), }, - link = new ExternalLinkButton(roomUrl) + linkButton = new ExternalLinkButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Margin = new MarginPadding { Horizontal = 6, Bottom = 4 }, - Alpha = showExternalLink ? 1 : 0, + Alpha = 0f, }, }; } + private void updateLink() + { + if (link == null) + linkButton.Hide(); + else + { + linkButton.Show(); + linkButton.Link = link; + } + } + protected override void Update() { base.Update(); - spriteText.MaxWidth = DrawWidth - link.LayoutSize.X; + spriteText.MaxWidth = DrawWidth - linkButton.LayoutSize.X; } } } From 3ac557907e4d2d280043109e7083dacee59b3621 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 26 Jun 2025 02:58:55 +0300 Subject: [PATCH 123/173] Add test coverage --- .../Visual/Multiplayer/TestSceneRoomPanel.cs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs index fee5e62958..037c5faae3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs @@ -165,23 +165,75 @@ namespace osu.Game.Tests.Visual.Multiplayer Name = "A host-only room", QueueMode = QueueMode.HostOnly, Type = MatchType.HeadToHead, + RoomID = 1337, }), new MultiplayerRoomPanel(new Room { Name = "An all-players, team-versus room", QueueMode = QueueMode.AllPlayers, - Type = MatchType.TeamVersus + Type = MatchType.TeamVersus, + RoomID = 1338, }), new MultiplayerRoomPanel(new Room { Name = "A round-robin room", QueueMode = QueueMode.AllPlayersRoundRobin, - Type = MatchType.HeadToHead + Type = MatchType.HeadToHead, + RoomID = 1339, }), } }); } + [Test] + public void TestRoomWithLongTitle() + { + AddStep("create rooms", () => Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new[] + { + new MultiplayerRoomPanel(new Room + { + Name = "This room has a very very long title enough to make the external link button reach the participants list on the right side unless the test window is very wide, at which point I don't know, hi.", + QueueMode = QueueMode.HostOnly, + Type = MatchType.HeadToHead, + RoomID = 1337, + }), + } + }); + } + + [Test] + public void TestRoomWithUpdatedRoomID() + { + Room room = null!; + + AddStep("create rooms", () => Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new[] + { + new MultiplayerRoomPanel(room = new Room + { + Name = "This room has a very very long title enough to make the external link button reach the participants list on the right side unless the test window is very wide, at which point I don't know, hi.", + QueueMode = QueueMode.HostOnly, + Type = MatchType.HeadToHead, + }), + } + }); + AddWaitStep("wait", 3); + AddStep("set room ID", () => room.RoomID = 1337); + AddWaitStep("wait", 3); + AddStep("clear room ID", () => room.RoomID = null); + } + private RoomPanel createLoungeRoom(Room room) { room.Host ??= new APIUser { Username = "peppy", Id = 2 }; From fa508245a1fa50b14a602a758d24581b3c6e9f40 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 26 Jun 2025 07:23:29 +0300 Subject: [PATCH 124/173] Support changing weight in text-based elements --- osu.Game/Graphics/OsuFont.cs | 8 ++ osu.Game/Localisation/FontStrings.cs | 44 +++++++++++ .../SkinnableComponentStrings.cs | 10 +++ .../Skinning/FontAdjustableSkinComponent.cs | 76 +++++++++++++++++-- 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 osu.Game/Localisation/FontStrings.cs diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index b314c602f5..22a2c9f37b 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -5,6 +5,8 @@ using System.ComponentModel; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Graphics { @@ -177,31 +179,37 @@ namespace osu.Game.Graphics /// /// Equivalent to weight 300. /// + [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Light))] Light = 300, /// /// Equivalent to weight 400. /// + [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Regular))] Regular = 400, /// /// Equivalent to weight 500. /// + [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Medium))] Medium = 500, /// /// Equivalent to weight 600. /// + [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.SemiBold))] SemiBold = 600, /// /// Equivalent to weight 700. /// + [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Bold))] Bold = 700, /// /// Equivalent to weight 900. /// + [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Black))] Black = 900 } } diff --git a/osu.Game/Localisation/FontStrings.cs b/osu.Game/Localisation/FontStrings.cs new file mode 100644 index 0000000000..72e3f3eaba --- /dev/null +++ b/osu.Game/Localisation/FontStrings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class FontStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Font"; + + /// + /// "Light" + /// + public static LocalisableString Light => new TranslatableString(getKey(@"light"), @"Light"); + + /// + /// "Regular" + /// + public static LocalisableString Regular => new TranslatableString(getKey(@"regular"), @"Regular"); + + /// + /// "Medium" + /// + public static LocalisableString Medium => new TranslatableString(getKey(@"medium"), @"Medium"); + + /// + /// "Semibold" + /// + public static LocalisableString SemiBold => new TranslatableString(getKey(@"semi_bold"), @"Semibold"); + + /// + /// "Bold" + /// + public static LocalisableString Bold => new TranslatableString(getKey(@"bold"), @"Bold"); + + /// + /// "Black" + /// + public static LocalisableString Black => new TranslatableString(getKey(@"black"), @"Black"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs index 66abf2bfd5..61d1137e6a 100644 --- a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -79,6 +79,16 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString TextColourDescription => new TranslatableString(getKey(@"text_colour_description"), @"The colour of the text."); + /// + /// "Text weight" + /// + public static LocalisableString TextWeight => new TranslatableString(getKey(@"text_weight"), @"Text weight"); + + /// + /// "The weight of the text." + /// + public static LocalisableString TextWeightDescription => new TranslatableString(getKey(@"text_weight_description"), @"The weight of the text."); + /// /// "Use relative size" /// diff --git a/osu.Game/Skinning/FontAdjustableSkinComponent.cs b/osu.Game/Skinning/FontAdjustableSkinComponent.cs index 0821edf7fc..1fda31afb7 100644 --- a/osu.Game/Skinning/FontAdjustableSkinComponent.cs +++ b/osu.Game/Skinning/FontAdjustableSkinComponent.cs @@ -1,13 +1,16 @@ // 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 osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation.SkinComponents; +using osu.Game.Overlays.Settings; namespace osu.Game.Skinning { @@ -21,6 +24,10 @@ namespace osu.Game.Skinning [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] public Bindable Font { get; } = new Bindable(Typeface.Torus); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextWeight), nameof(SkinnableComponentStrings.TextWeightDescription), + SettingControlType = typeof(WeightDropdown))] + public Bindable TextWeight { get; } = new Bindable(FontWeight.Regular); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] public BindableColour4 TextColour { get; } = new BindableColour4(Colour4.White); @@ -35,16 +42,69 @@ namespace osu.Game.Skinning { base.LoadComplete(); - Font.BindValueChanged(e => - { - // We only have bold weight for venera, so let's force that. - FontWeight fontWeight = e.NewValue == Typeface.Venera ? FontWeight.Bold : FontWeight.Regular; - - FontUsage f = OsuFont.GetFont(e.NewValue, weight: fontWeight); - SetFont(f); - }, true); + Font.BindValueChanged(_ => updateFont()); + TextWeight.BindValueChanged(_ => updateFont(), true); TextColour.BindValueChanged(e => SetTextColour(e.NewValue), true); } + + private void updateFont() => SetFont(OsuFont.GetFont(Font.Value, weight: TextWeight.Value)); + + private partial class WeightDropdown : SettingsDropdown + { + public FontAdjustableSkinComponent FontComponent => (FontAdjustableSkinComponent)SettingSourceObject; + protected override OsuDropdown CreateDropdown() => new DropdownControl(this); + + private new partial class DropdownControl : SettingsDropdown.DropdownControl + { + private readonly WeightDropdown settingsDropdown; + + private IBindable font = null!; + + public DropdownControl(WeightDropdown settingsDropdown) + { + this.settingsDropdown = settingsDropdown; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + font = settingsDropdown.FontComponent.Font.GetBoundCopy(); + font.BindValueChanged(_ => updateItems(), true); + } + + private void updateItems() + { + ClearItems(); + + switch (font.Value) + { + case Typeface.Venera: + AddDropdownItem(FontWeight.Light); + AddDropdownItem(FontWeight.Bold); + AddDropdownItem(FontWeight.Black); + + Current.Default = FontWeight.Bold; + + if (!Items.Contains(Current.Value)) + Current.SetDefault(); + break; + + default: + AddDropdownItem(FontWeight.Light); + AddDropdownItem(FontWeight.Regular); + AddDropdownItem(FontWeight.SemiBold); + AddDropdownItem(FontWeight.Bold); + + Current.Default = FontWeight.Regular; + + if (!Items.Contains(Current.Value)) + Current.SetDefault(); + break; + } + } + } + } } } From cd354a0de827a0601d8f78fdd74c6e61d64c55da Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 27 Jun 2025 00:58:10 +0300 Subject: [PATCH 125/173] Remove font weight localisation --- osu.Game/Graphics/OsuFont.cs | 8 ----- osu.Game/Localisation/FontStrings.cs | 44 ---------------------------- 2 files changed, 52 deletions(-) delete mode 100644 osu.Game/Localisation/FontStrings.cs diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index 22a2c9f37b..b314c602f5 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -5,8 +5,6 @@ using System.ComponentModel; using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; -using osu.Game.Localisation; namespace osu.Game.Graphics { @@ -179,37 +177,31 @@ namespace osu.Game.Graphics /// /// Equivalent to weight 300. /// - [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Light))] Light = 300, /// /// Equivalent to weight 400. /// - [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Regular))] Regular = 400, /// /// Equivalent to weight 500. /// - [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Medium))] Medium = 500, /// /// Equivalent to weight 600. /// - [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.SemiBold))] SemiBold = 600, /// /// Equivalent to weight 700. /// - [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Bold))] Bold = 700, /// /// Equivalent to weight 900. /// - [LocalisableDescription(typeof(FontStrings), nameof(FontStrings.Black))] Black = 900 } } diff --git a/osu.Game/Localisation/FontStrings.cs b/osu.Game/Localisation/FontStrings.cs deleted file mode 100644 index 72e3f3eaba..0000000000 --- a/osu.Game/Localisation/FontStrings.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Localisation; - -namespace osu.Game.Localisation -{ - public static class FontStrings - { - private const string prefix = @"osu.Game.Resources.Localisation.Font"; - - /// - /// "Light" - /// - public static LocalisableString Light => new TranslatableString(getKey(@"light"), @"Light"); - - /// - /// "Regular" - /// - public static LocalisableString Regular => new TranslatableString(getKey(@"regular"), @"Regular"); - - /// - /// "Medium" - /// - public static LocalisableString Medium => new TranslatableString(getKey(@"medium"), @"Medium"); - - /// - /// "Semibold" - /// - public static LocalisableString SemiBold => new TranslatableString(getKey(@"semi_bold"), @"Semibold"); - - /// - /// "Bold" - /// - public static LocalisableString Bold => new TranslatableString(getKey(@"bold"), @"Bold"); - - /// - /// "Black" - /// - public static LocalisableString Black => new TranslatableString(getKey(@"black"), @"Black"); - - private static string getKey(string key) => $@"{prefix}:{key}"; - } -} From 3dbb1e15b643f17442a94edcbc159af5cf5a8069 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 27 Jun 2025 03:52:09 +0300 Subject: [PATCH 126/173] Fix potential null reference in `RoomNameLine` --- osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index b9f84b4fa4..3610995b2c 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -569,8 +569,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public partial class RoomNameLine : FillFlowContainer { - private TruncatingSpriteText spriteText = null!; - private ExternalLinkButton linkButton = null!; + private readonly TruncatingSpriteText spriteText; + private readonly ExternalLinkButton linkButton; public LocalisableString Text { @@ -590,8 +590,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - [BackgroundDependencyLoader] - private void load() + public RoomNameLine() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; From e713a68c4936b7f4d9f84d2904dd5e995f7b8820 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 27 Jun 2025 14:32:58 +0900 Subject: [PATCH 127/173] Attempt to fix flaky test `TestExitWithHoldDisabled` See https://github.com/ppy/osu/actions/runs/15915618971/job/44892620536#step:5:203. Note that the `DialogOverlay` only finishes loading after the until step failure. --- osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 8ba914c05f..2a755b46b3 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -1201,6 +1201,8 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitWithHoldDisabled() { + AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + AddStep("set hold delay to 0", () => Game.LocalConfig.SetValue(OsuSetting.UIHoldActivationDelay, 0.0)); AddStep("press escape twice rapidly", () => From 5485abd3a53102c6bca1e3eec790b304cba9fec6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 27 Jun 2025 17:09:29 +0900 Subject: [PATCH 128/173] Remove pointless tooltip I agree that these are pointless and we should probably remove the others and stop wasting localiser's time on these. --- .../Localisation/SkinComponents/SkinnableComponentStrings.cs | 5 ----- osu.Game/Skinning/FontAdjustableSkinComponent.cs | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs index 61d1137e6a..35ed1ea55c 100644 --- a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -84,11 +84,6 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString TextWeight => new TranslatableString(getKey(@"text_weight"), @"Text weight"); - /// - /// "The weight of the text." - /// - public static LocalisableString TextWeightDescription => new TranslatableString(getKey(@"text_weight_description"), @"The weight of the text."); - /// /// "Use relative size" /// diff --git a/osu.Game/Skinning/FontAdjustableSkinComponent.cs b/osu.Game/Skinning/FontAdjustableSkinComponent.cs index 1fda31afb7..f2d8c9e440 100644 --- a/osu.Game/Skinning/FontAdjustableSkinComponent.cs +++ b/osu.Game/Skinning/FontAdjustableSkinComponent.cs @@ -24,8 +24,7 @@ namespace osu.Game.Skinning [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] public Bindable Font { get; } = new Bindable(Typeface.Torus); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextWeight), nameof(SkinnableComponentStrings.TextWeightDescription), - SettingControlType = typeof(WeightDropdown))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextWeight), SettingControlType = typeof(WeightDropdown))] public Bindable TextWeight { get; } = new Bindable(FontWeight.Regular); [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] From 52fad813713ab6ad4605b84e7f608dd9c11f50bf Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 27 Jun 2025 17:19:11 +0900 Subject: [PATCH 129/173] Fix lag when checking for update --- osu.Game/Updater/UpdateManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 65b4770174..335f6085a9 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -88,7 +88,7 @@ namespace osu.Game.Updater /// Immediately checks for any available update. /// /// true if any updates are available, false otherwise. - public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) => await Task.Run(async () => { if (!CanCheckForUpdate) return false; @@ -100,7 +100,7 @@ namespace osu.Game.Updater await lastCts.CancelAsync().ConfigureAwait(false); return await PerformUpdateCheck(cts.Token).ConfigureAwait(false); - } + }, cancellationToken).ConfigureAwait(false); /// /// Performs an asynchronous check for application updates. From c53a7aa2fcbc1a0f523b2c7c0cf66322943b87f8 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 27 Jun 2025 19:36:55 +0900 Subject: [PATCH 130/173] Fix flaky tests due to async disposal --- .../Mods/TestSceneOsuModRelax.cs | 46 ++++++++++--------- .../Tests/Visual/ReplayStabilityTestScene.cs | 5 ++ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs index 1bb2f24c1c..b4298344b8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs @@ -13,7 +13,6 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Tests.Visual; using osuTK; @@ -22,21 +21,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { public partial class TestSceneOsuModRelax : OsuModTestScene { - private readonly HitCircle hitObject; - private readonly HitWindows hitWindows = new OsuHitWindows(); - - public TestSceneOsuModRelax() - { - hitWindows.SetDifficulty(9); - - hitObject = new HitCircle - { - StartTime = 1000, - Position = new Vector2(100, 100), - HitWindows = hitWindows - }; - } - protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new ModRelaxTestPlayer(CurrentTestData, AllowFail); [Test] @@ -46,12 +30,21 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Autoplay = false, CreateBeatmap = () => new Beatmap { - HitObjects = new List { hitObject } + Difficulty = { OverallDifficulty = 9 }, + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(100, 100), + HitWindows = new OsuHitWindows() + } + } }, ReplayFrames = new List { new OsuReplayFrame(0, new Vector2()), - new OsuReplayFrame(hitObject.StartTime, hitObject.Position), + new OsuReplayFrame(100, new Vector2(100)), }, PassCondition = () => Player.ScoreProcessor.Combo.Value == 1 }); @@ -63,13 +56,22 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Autoplay = false, CreateBeatmap = () => new Beatmap { - HitObjects = new List { hitObject } + Difficulty = { OverallDifficulty = 9 }, + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(100, 100), + HitWindows = new OsuHitWindows() + } + } }, ReplayFrames = new List { - new OsuReplayFrame(0, new Vector2(hitObject.X - 22, hitObject.Y - 22)), // must be an edge hit for the cursor to not stay on the object for too long - new OsuReplayFrame(hitObject.StartTime - OsuModRelax.RELAX_LENIENCY, new Vector2(hitObject.X - 22, hitObject.Y - 22)), - new OsuReplayFrame(hitObject.StartTime, new Vector2(0)), + new OsuReplayFrame(0, new Vector2(78, 78)), // must be an edge hit for the cursor to not stay on the object for too long + new OsuReplayFrame(1000 - OsuModRelax.RELAX_LENIENCY, new Vector2(78, 78)), + new OsuReplayFrame(1000, new Vector2(0)), }, PassCondition = () => Player.ScoreProcessor.Combo.Value == 1 }); diff --git a/osu.Game/Tests/Visual/ReplayStabilityTestScene.cs b/osu.Game/Tests/Visual/ReplayStabilityTestScene.cs index af41617a7b..a84fb86200 100644 --- a/osu.Game/Tests/Visual/ReplayStabilityTestScene.cs +++ b/osu.Game/Tests/Visual/ReplayStabilityTestScene.cs @@ -57,6 +57,11 @@ namespace osu.Game.Tests.Visual AddStep(@"exit player", () => currentPlayer.Exit()); + // The incoming beatmap is ruleset-typed in every usage, so the incoming hitobjects will be used as-is rather than being converted. + // Because we'll be re-using the beatmap (thus also the hitobjects), we need to make sure the previous player has been fully disposed. + AddUntilStep("player exited", () => !currentPlayer.IsCurrentScreen()); + AddStep("dispose player", () => currentPlayer.Dispose()); + AddStep(@"encode and decode score", () => { var encoder = new LegacyScoreEncoder(originalScore, beatmap); From c7cd3a984285b6770ff69983186e16908bbb507e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 27 Jun 2025 12:44:50 +0200 Subject: [PATCH 131/173] Fix beatmap skin sample lookups falling back to non-custom sample banks if the custom sample bank sample was not found Closes https://github.com/ppy/osu/issues/33900. I think. Stable's sample lookup logic is horrible. The user in the issue claimed they were hearing `drum-hitfinish2`, but they were really hearing `drum-hitfinish`, because they're the same `.wav` file in the beatmap. Now the reason *why* they were hearind `drum-hitfinish` is that the sample control point was specifying something like: 23946,-200,4,2,4,40,0,0 To decipher, this is: - default sample bank of soft - custom sample bank of 4 Taking one of the objects affected, namely 00:23:946 (2) - that's a slider with finish addition and drum addition bank on the slider head. The slider head is thus attempting to play `soft-hitnormal4` and `drum-hitfinish4`. Neither `soft-hitnormal4` or `soft-hitnormal` exist in the beatmap, so that plays fine via falling back to user skin's `soft-hitnormal`, but `drum-hitfinish4` ends up falling back to `drum-hitfinish` which *does* exist in the beatmap skin and thus plays wrongly from the beatmap skin rather than the user skin. I have no idea how to ensure this is correct across every beatmap and skin out there so my approach is to just spray and pray (and rely on issue reports I guess). I *think* this matches the stable logic which is nestled within https://github.com/peppy/osu-stable-reference/blob/a5e5fe6ef240505d13526cf32783cad261e9bd8b/osu!/Audio/AudioEngine.cs#L1136-L1230 but honestly if you put a gun to my head I couldn't be sure if it matches completely in every possible circumstance or not. --- osu.Game/Skinning/LegacySkin.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index bbed434b3a..b648299787 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -594,12 +594,17 @@ namespace osu.Game.Skinning { var lookupNames = hitSample.LookupNames.SelectMany(getFallbackSampleNames); - if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix)) + if (!string.IsNullOrEmpty(hitSample.Suffix)) { - // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin. + // for compatibility with stable: + // - if the skin can use custom sample banks, it MUST use the custom sample bank suffix. it is not allowed to fall back to a non-custom sound. + // - if the skin cannot use custom sample banks, it MUST NOT use the custom sample bank suffix. // using .EndsWith() is intentional as it ensures parity in all edge cases - // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not). - lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); + // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply). + if (UseCustomSampleBanks) + lookupNames = lookupNames.Where(name => name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); + else + lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); } foreach (string l in lookupNames) From 360ba548dc3048f5a2ae3eb2bf233a282aa258c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 27 Jun 2025 13:24:45 +0200 Subject: [PATCH 132/173] Explicitly explain to users that failed plays do not give pp on results screen Addresses https://osu.ppy.sh/community/forums/topics/2096912. --- osu.Game/Localisation/ResultsScreenStrings.cs | 5 +++++ .../Ranking/Expanded/Statistics/PerformanceStatistic.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/osu.Game/Localisation/ResultsScreenStrings.cs b/osu.Game/Localisation/ResultsScreenStrings.cs index 54e7717af9..143d5b70bc 100644 --- a/osu.Game/Localisation/ResultsScreenStrings.cs +++ b/osu.Game/Localisation/ResultsScreenStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString NoPPForUnrankedMods => new TranslatableString(getKey(@"no_pp_for_unranked_mods"), @"Performance points are not granted for this score because of unranked mods."); + /// + /// "Performance points are not granted for failed scores." + /// + public static LocalisableString NoPPForFailedScores => new TranslatableString(getKey(@"no_pp_for_failed_scores"), @"Performance points are not granted for failed scores."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 7d155e32b0..8a84501a17 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -79,6 +79,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics Alpha = 0.5f; TooltipText = ResultsScreenStrings.NoPPForUnrankedMods; } + else if (scoreInfo.Rank == ScoreRank.F) + { + Alpha = 0.5f; + TooltipText = ResultsScreenStrings.NoPPForFailedScores; + } else { Alpha = 1f; From edafac2aaa348eda4d0e0b71d3b066db90eaf9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 27 Jun 2025 14:06:30 +0200 Subject: [PATCH 133/173] Adjust tests to pass (and add test coverage of fail case) --- .../TestSceneOsuHitObjectSamples.cs | 21 ++++++++++--------- .../TestSceneTaikoHitObjectSamples.cs | 21 ++++++++++--------- .../Gameplay/TestSceneHitObjectSamples.cs | 3 --- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs index 61cc10f284..ddea5eed87 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs @@ -14,26 +14,27 @@ namespace osu.Game.Rulesets.Osu.Tests protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneOsuHitObjectSamples))); - [TestCase("normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + [TestCase("normal-hitnormal2", "normal-hitnormal")] + [TestCase("hitnormal", "hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(expectedSample, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); - AssertBeatmapLookup(expectedSample); + AssertBeatmapLookup(beatmapSkinSampleName); } - [TestCase("normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + [TestCase("", "normal-hitnormal")] + [TestCase("normal-hitnormal", "normal-hitnormal")] + [TestCase("", "hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(string.Empty, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); - AssertUserLookup(expectedSample); + AssertUserLookup(userSkinSampleName); } [TestCase("normal-hitnormal2")] diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs index 1d1e82fb07..b1df133c30 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs @@ -14,26 +14,27 @@ namespace osu.Game.Rulesets.Taiko.Tests protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); - [TestCase("taiko-normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + [TestCase("taiko-normal-hitnormal2", "taiko-normal-hitnormal")] + [TestCase("hitnormal", "hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(expectedSample, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); - AssertBeatmapLookup(expectedSample); + AssertBeatmapLookup(beatmapSkinSampleName); } - [TestCase("taiko-normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + [TestCase("", "taiko-normal-hitnormal")] + [TestCase("taiko-normal-hitnormal", "taiko-normal-hitnormal")] + [TestCase("", "hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(string.Empty, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); - AssertUserLookup(expectedSample); + AssertUserLookup(userSkinSampleName); } [TestCase("taiko-normal-hitnormal2")] diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs index d198ef5074..c9f5f50232 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -64,11 +64,9 @@ namespace osu.Game.Tests.Gameplay /// /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin: /// normal-hitnormal2 - /// normal-hitnormal /// hitnormal /// [TestCase("normal-hitnormal2")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestDefaultCustomSampleFromBeatmap(string expectedSample) { @@ -162,7 +160,6 @@ namespace osu.Game.Tests.Gameplay /// Tests that a control point that provides a custom sample of 2 causes . /// [TestCase("normal-hitnormal2")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestControlPointCustomSampleFromBeatmap(string sampleName) { From 5c89644d5893f15990a4f12dfc8a0d50f9c8c5ed Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 29 Jun 2025 00:24:12 +0900 Subject: [PATCH 134/173] Fix flaky `TestSceneGameplaySamplePlayback` test See: https://github.com/ppy/osu/actions/runs/15924307907/job/44920278903 Similar null-checks within `Update()` are present in the `PlayerTestScene` superclass. --- .../Visual/Gameplay/TestSceneGameplaySamplePlayback.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 2334b1c6d6..84b312d5ee 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -9,7 +9,6 @@ using osu.Game.Audio; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Screens.Play; using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay @@ -74,8 +73,8 @@ namespace osu.Game.Tests.Visual.Gameplay // // We want to keep seeking while asserting various test conditions, so // continue to seek until we unset the flag. - var gameplayClockContainer = Player.ChildrenOfType().First(); - gameplayClockContainer.Seek(gameplayClockContainer.CurrentTime > 30000 ? 0 : 60000); + var gameplayClockContainer = Player?.GameplayClockContainer; + gameplayClockContainer?.Seek(gameplayClockContainer.CurrentTime > 30000 ? 0 : 60000); } } From 9155f566ecb11cf9da75729c93161f4ae2d38fec Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 16:09:23 +0900 Subject: [PATCH 135/173] Fix random sound effect not playing correctly --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index b09490ce32..d33d5dbd87 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -667,7 +667,9 @@ namespace osu.Game.Screens.SelectV2 return false; } - Scheduler.Add(() => + // CurrentSelectionItem won't be valid until UpdaterAfterChildren. + // We probably want to fix this at some point since a few places are working-around this quirk. + ScheduleAfterChildren(() => { if (selectionBefore != null && CurrentSelectionItem != null) playSpinSample(distanceBetween(selectionBefore, CurrentSelectionItem), carouselItems.Count); From bf8b6754dc3f54d205af30dc12ccc0aba9593e21 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 16:09:34 +0900 Subject: [PATCH 136/173] Play error sound when random selection fails --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 10 ++++++---- osu.Game/Screens/SelectV2/SongSelect.cs | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index d33d5dbd87..d343bd9e12 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -771,12 +771,12 @@ namespace osu.Game.Screens.SelectV2 return true; } - public void PreviousRandom() + public bool PreviousRandom() { var carouselItems = GetCarouselItems(); if (carouselItems?.Any() != true) - return; + return false; while (randomHistory.Any()) { @@ -786,7 +786,7 @@ namespace osu.Game.Screens.SelectV2 var previousBeatmapItem = carouselItems.FirstOrDefault(i => i.Model is BeatmapInfo b && b.Equals(previousBeatmap)); if (previousBeatmapItem == null) - return; + return false; if (CurrentSelection is BeatmapInfo beatmapInfo) { @@ -800,8 +800,10 @@ namespace osu.Game.Screens.SelectV2 } RequestSelection(previousBeatmap); - break; + return true; } + + return false; } private double distanceBetween(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT); diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 4c30662bd4..e26c72575a 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -103,6 +105,8 @@ namespace osu.Game.Screens.SelectV2 public override bool ShowFooter => true; + private Sample? errorSample; + [Resolved] private OsuGameBase? game { get; set; } @@ -128,8 +132,10 @@ namespace osu.Game.Screens.SelectV2 private IDialogOverlay? dialogOverlay { get; set; } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + errorSample = audio.Samples.Get(@"UI/generic-error"); + AddRangeInternal(new Drawable[] { new GlobalScrollAdjustsVolume(), @@ -286,8 +292,16 @@ namespace osu.Game.Screens.SelectV2 }, new FooterButtonRandom { - NextRandom = () => carousel.NextRandom(), - PreviousRandom = () => carousel.PreviousRandom() + NextRandom = () => + { + if (!carousel.NextRandom()) + errorSample?.Play(); + }, + PreviousRandom = () => + { + if (!carousel.PreviousRandom()) + errorSample?.Play(); + } }, new FooterButtonOptions { From d7b76400553ffab3ab3f1179bbf9366faf0f2ee1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 16:21:41 +0900 Subject: [PATCH 137/173] Refactor distance-between-panels implementation The new version wasn't really working as expected, because the Y position measurement only considered visible panels, while it was being divided over all panels (including non-expanded groups or sets). Rather than trying to divide across all panels, just choose a sane number for the "highest pitch" sound and work with that as a constant. --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index d343bd9e12..cf7403972c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -672,7 +672,7 @@ namespace osu.Game.Screens.SelectV2 ScheduleAfterChildren(() => { if (selectionBefore != null && CurrentSelectionItem != null) - playSpinSample(distanceBetween(selectionBefore, CurrentSelectionItem), carouselItems.Count); + playSpinSample(visiblePanelCountBetweenItems(selectionBefore, CurrentSelectionItem)); }); return true; @@ -794,9 +794,9 @@ namespace osu.Game.Screens.SelectV2 previouslyVisitedRandomBeatmaps.Remove(beatmapInfo); if (CurrentSelectionItem == null) - playSpinSample(0, carouselItems.Count); + playSpinSample(0); else - playSpinSample(distanceBetween(previousBeatmapItem, CurrentSelectionItem), carouselItems.Count); + playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem)); } RequestSelection(previousBeatmap); @@ -806,15 +806,15 @@ namespace osu.Game.Screens.SelectV2 return false; } - private double distanceBetween(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT); + private double visiblePanelCountBetweenItems(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT); - private void playSpinSample(double distance, int count) + private void playSpinSample(double distance) { var chan = spinSample?.GetChannel(); if (chan != null) { - chan.Frequency.Value = 1f + Math.Min(1f, distance / count); + chan.Frequency.Value = 1f + Math.Clamp(distance / 200, 0, 1); chan.Play(); } From d1c0d58f2e33f0535563f25f324f0d8b4bf73269 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 16:49:43 +0900 Subject: [PATCH 138/173] Fix player settings no longer collapsing correctly Regressed with https://github.com/ppy/osu/pull/33621 for obvious reasons. Tachyon doing its job, caught this before hitting a proper release. --- .../Visual/Gameplay/TestSceneReplayPlayer.cs | 15 +++++++++++ .../Graphics/Containers/ExpandingContainer.cs | 25 ++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index 5db7a78983..b3ed4135a9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK; @@ -192,6 +193,20 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("player failed after 10000", () => Player.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(10000)); } + [Test] + public void TestPlayerLoaderSettingsHover() + { + loadPlayerWithBeatmap(); + + AddUntilStep("wait for settings overlay hidden", () => settingsOverlay().Expanded.Value, () => Is.False); + AddStep("move mouse to right of screen", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopRight)); + AddUntilStep("wait for settings overlay visible", () => settingsOverlay().Expanded.Value, () => Is.True); + AddStep("move mouse to centre of screen", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); + AddUntilStep("wait for settings overlay hidden", () => settingsOverlay().Expanded.Value, () => Is.False); + + PlayerSettingsOverlay settingsOverlay() => Player.ChildrenOfType().Single(); + } + private void loadPlayerWithBeatmap(IBeatmap? beatmap = null) { AddStep("create player", () => diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index ad5c65c10e..4b70fd6987 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -41,24 +40,20 @@ namespace osu.Game.Graphics.Containers RelativeSizeAxes = Axes.Y; Width = contractedWidth; - FillFlow = new FillFlowContainer - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - }; - } - - [BackgroundDependencyLoader] - private void load() - { InternalChild = CreateScrollContainer().With(s => { s.RelativeSizeAxes = Axes.Both; s.ScrollbarVisible = false; - }).WithChild(FillFlow); + }).WithChild( + FillFlow = new FillFlowContainer + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } + ); } protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); From 2b92b59504eab5e29b2d2951a09bedb8b5ef2f00 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 30 Jun 2025 11:58:38 +0300 Subject: [PATCH 139/173] Remove unnecessary skin settings descriptions --- .../BeatmapAttributeTextStrings.cs | 5 ---- .../SkinnableComponentStrings.cs | 30 ------------------- .../Screens/Play/HUD/ArgonAccuracyCounter.cs | 2 +- .../Screens/Play/HUD/ArgonComboCounter.cs | 2 +- .../Play/HUD/ArgonPerformancePointsCounter.cs | 2 +- .../Screens/Play/HUD/ArgonScoreCounter.cs | 2 +- .../Screens/Play/HUD/ArgonSongProgress.cs | 2 +- osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs | 2 +- .../Screens/Play/HUD/DefaultSongProgress.cs | 2 +- .../Components/BeatmapAttributeText.cs | 2 +- osu.Game/Skinning/Components/BoxElement.cs | 2 +- osu.Game/Skinning/Components/TextElement.cs | 2 +- .../Skinning/FontAdjustableSkinComponent.cs | 4 +-- osu.Game/Skinning/SkinnableSprite.cs | 2 +- 14 files changed, 13 insertions(+), 48 deletions(-) diff --git a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs index 390a6f9ca4..4ddffe615f 100644 --- a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs +++ b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs @@ -14,11 +14,6 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), @"Attribute"); - /// - /// "The attribute to be displayed." - /// - public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), @"The attribute to be displayed."); - /// /// "Template" /// diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs index 35ed1ea55c..2f34987e8e 100644 --- a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -14,31 +14,16 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString SpriteName => new TranslatableString(getKey(@"sprite_name"), @"Sprite name"); - /// - /// "The filename of the sprite" - /// - public static LocalisableString SpriteNameDescription => new TranslatableString(getKey(@"sprite_name_description"), @"The filename of the sprite"); - /// /// "Font" /// public static LocalisableString Font => new TranslatableString(getKey(@"font"), @"Font"); - /// - /// "The font to use." - /// - public static LocalisableString FontDescription => new TranslatableString(getKey(@"font_description"), @"The font to use."); - /// /// "Text" /// public static LocalisableString TextElementText => new TranslatableString(getKey(@"text_element_text"), @"Text"); - /// - /// "The text to be displayed." - /// - public static LocalisableString TextElementTextDescription => new TranslatableString(getKey(@"text_element_text_description"), @"The text to be displayed."); - /// /// "Corner radius" /// @@ -54,31 +39,16 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString ShowLabel => new TranslatableString(getKey(@"show_label"), @"Show label"); - /// - /// "Whether the component's label should be shown." - /// - public static LocalisableString ShowLabelDescription => new TranslatableString(getKey(@"show_label_description"), @"Whether the component's label should be shown."); - /// /// "Colour" /// public static LocalisableString Colour => new TranslatableString(getKey(@"colour"), @"Colour"); - /// - /// "The colour of the component." - /// - public static LocalisableString ColourDescription => new TranslatableString(getKey(@"colour_description"), @"The colour of the component."); - /// /// "Text colour" /// public static LocalisableString TextColour => new TranslatableString(getKey(@"text_colour"), @"Text colour"); - /// - /// "The colour of the text." - /// - public static LocalisableString TextColourDescription => new TranslatableString(getKey(@"text_colour_description"), @"The colour of the text."); - /// /// "Text weight" /// diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index d7fe1f52ff..c4cf52c254 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index e82e8f4b6f..22d65601cd 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs index 1620da2f2e..8e9360920c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); public override bool IsValid diff --git a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs index 8658651407..f000a5977c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs @@ -28,7 +28,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs index 8dc5d60352..5b2efb447b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] public BindableBool UseRelativeSize { get; } = new BindableBool(true); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); [Resolved] diff --git a/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs b/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs index 46a658cd1c..810100532b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs +++ b/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs @@ -22,7 +22,7 @@ namespace osu.Game.Screens.Play.HUD [SettingSource("Inverted shear")] public BindableBool InvertShear { get; } = new BindableBool(); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Color4Extensions.FromHex("#66CCFF")); public ArgonWedgePiece() diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index 672017750d..06d541d838 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -40,7 +40,7 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] public BindableBool UseRelativeSize { get; } = new BindableBool(true); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); [Resolved] diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index 76c8d54f50..58821f869a 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -29,7 +29,7 @@ namespace osu.Game.Skinning.Components [UsedImplicitly] public partial class BeatmapAttributeText : FontAdjustableSkinComponent { - [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Attribute), nameof(BeatmapAttributeTextStrings.AttributeDescription))] + [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Attribute))] public Bindable Attribute { get; } = new Bindable(BeatmapAttribute.StarRating); [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))] diff --git a/osu.Game/Skinning/Components/BoxElement.cs b/osu.Game/Skinning/Components/BoxElement.cs index 7f052a8523..ddfa1aa446 100644 --- a/osu.Game/Skinning/Components/BoxElement.cs +++ b/osu.Game/Skinning/Components/BoxElement.cs @@ -27,7 +27,7 @@ namespace osu.Game.Skinning.Components Precision = 0.01f }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); public BoxElement() diff --git a/osu.Game/Skinning/Components/TextElement.cs b/osu.Game/Skinning/Components/TextElement.cs index 6e875c5590..a271857c03 100644 --- a/osu.Game/Skinning/Components/TextElement.cs +++ b/osu.Game/Skinning/Components/TextElement.cs @@ -15,7 +15,7 @@ namespace osu.Game.Skinning.Components [UsedImplicitly] public partial class TextElement : FontAdjustableSkinComponent { - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextElementText), nameof(SkinnableComponentStrings.TextElementTextDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextElementText))] public Bindable Text { get; } = new Bindable("Circles!"); private readonly OsuSpriteText text; diff --git a/osu.Game/Skinning/FontAdjustableSkinComponent.cs b/osu.Game/Skinning/FontAdjustableSkinComponent.cs index f2d8c9e440..eba29e9b79 100644 --- a/osu.Game/Skinning/FontAdjustableSkinComponent.cs +++ b/osu.Game/Skinning/FontAdjustableSkinComponent.cs @@ -21,13 +21,13 @@ namespace osu.Game.Skinning { public bool UsesFixedAnchor { get; set; } - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font))] public Bindable Font { get; } = new Bindable(Typeface.Torus); [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextWeight), SettingControlType = typeof(WeightDropdown))] public Bindable TextWeight { get; } = new Bindable(FontWeight.Regular); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour))] public BindableColour4 TextColour { get; } = new BindableColour4(Colour4.White); /// diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 47618f6296..49ce7e48ab 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -29,7 +29,7 @@ namespace osu.Game.Skinning [Resolved] private TextureStore textures { get; set; } = null!; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.SpriteName), nameof(SkinnableComponentStrings.SpriteNameDescription), SettingControlType = typeof(SpriteSelectorControl))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.SpriteName), SettingControlType = typeof(SpriteSelectorControl))] public Bindable SpriteName { get; } = new Bindable(string.Empty); [Resolved] From a8e3ce9af15bc392866a57d5503ede8d7005398f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 11:03:06 +0200 Subject: [PATCH 140/173] Fix typo --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index cf7403972c..ce7bd7582e 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -667,7 +667,7 @@ namespace osu.Game.Screens.SelectV2 return false; } - // CurrentSelectionItem won't be valid until UpdaterAfterChildren. + // CurrentSelectionItem won't be valid until UpdateAfterChildren. // We probably want to fix this at some point since a few places are working-around this quirk. ScheduleAfterChildren(() => { From 535d9f5b589f6987d7ca337d24ef5b238f338c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 11:50:31 +0200 Subject: [PATCH 141/173] Read & output combo indices in timestamps in catch editor Addresses https://github.com/ppy/osu/discussions/33912 for catch specifically. Code copy-pasted from osu! ruleset. I'm leaving taiko be because new combo still doesn't make any sense in taiko. It'd probably either have to be object index in beatmap period instead of index in combo, or the `kdkdkdkdkkkdkdkkkdkkdkd` notation people use. --- .../Edit/CatchHitObjectComposer.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index dfe9dc9dd8..370eb37d16 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -219,5 +220,40 @@ namespace osu.Game.Rulesets.Catch.Edit distanceSnapGrid.StartTime = sourceHitObject.GetEndTime(); distanceSnapGrid.StartX = sourceHitObject.EffectiveX; } + + #region Clipboard handling + + public override string ConvertSelectionToString() + => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + + // 1,2,3,4 ... + private static readonly Regex selection_regex = new Regex(@"^\d+(,\d+)*$", RegexOptions.Compiled); + + public override void SelectFromTimestamp(double timestamp, string objectDescription) + { + if (!selection_regex.IsMatch(objectDescription)) + return; + + List remainingHitObjects = EditorBeatmap.HitObjects.Cast().Where(h => h.StartTime >= timestamp).ToList(); + string[] splitDescription = objectDescription.Split(','); + + for (int i = 0; i < splitDescription.Length; i++) + { + if (!int.TryParse(splitDescription[i], out int combo) || combo < 1) + continue; + + CatchHitObject? current = remainingHitObjects.FirstOrDefault(h => h.IndexInCurrentCombo + 1 == combo); + + if (current == null) + continue; + + EditorBeatmap.SelectedHitObjects.Add(current); + + if (i < splitDescription.Length - 1) + remainingHitObjects = remainingHitObjects.Where(h => h != current && h.StartTime >= current.StartTime).ToList(); + } + } + + #endregion } } From 28caa03d21fc1c9b0f2d84c90d1813821c3f7bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 12:19:54 +0200 Subject: [PATCH 142/173] Fix Difficulty Adjust extended mod icon information not showing with extended limits active - Closes https://github.com/ppy/osu/issues/33522. - Alternative to / closes https://github.com/ppy/osu/pull/33561. --- .../Mods/CatchModDifficultyAdjust.cs | 2 +- .../Mods/OsuModDifficultyAdjust.cs | 2 +- .../Mods/TaikoModDifficultyAdjust.cs | 2 +- osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs | 41 +++++++++---------- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index c300afa79f..e4a910700c 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Mods { get { - if (UserAdjustedSettingsCount != 1) + if (!IsExactlyOneSettingChanged(CircleSize, ApproachRate, OverallDifficulty, DrainRate)) return string.Empty; if (!CircleSize.IsDefault) return format("CS", CircleSize); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 1d94ac6335..1c3b7360bc 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Mods { get { - if (UserAdjustedSettingsCount != 1) + if (!IsExactlyOneSettingChanged(CircleSize, ApproachRate, OverallDifficulty, DrainRate)) return string.Empty; if (!CircleSize.IsDefault) return format("CS", CircleSize); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 57b57555c2..296342ed97 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { get { - if (UserAdjustedSettingsCount != 1) + if (!IsExactlyOneSettingChanged(ScrollSpeed, OverallDifficulty, DrainRate)) return string.Empty; if (!ScrollSpeed.IsDefault) return format("SC", ScrollSpeed); diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index 4fd9916b89..dbc690bd15 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Mods { get { - if (UserAdjustedSettingsCount != 1) + if (!IsExactlyOneSettingChanged(OverallDifficulty, DrainRate)) return string.Empty; if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); @@ -84,6 +84,24 @@ namespace osu.Game.Rulesets.Mods } } + protected bool IsExactlyOneSettingChanged(params DifficultyBindable[] difficultySettings) + { + DifficultyBindable? changedSetting = null; + + foreach (var setting in difficultySettings) + { + if (setting.IsDefault) + continue; + + if (changedSetting != null) + return false; + + changedSetting = setting; + } + + return changedSetting != null; + } + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get @@ -107,26 +125,5 @@ namespace osu.Game.Rulesets.Mods if (DrainRate.Value != null) difficulty.DrainRate = DrainRate.Value.Value; if (OverallDifficulty.Value != null) difficulty.OverallDifficulty = OverallDifficulty.Value.Value; } - - /// - /// The number of settings on this mod instance which have been adjusted by the user from their default values. - /// - protected int UserAdjustedSettingsCount - { - get - { - int count = 0; - - foreach (var (_, property) in this.GetSettingsSourceProperties()) - { - var bindable = (IBindable)property.GetValue(this)!; - - if (!bindable.IsDefault) - count++; - } - - return count; - } - } } } From cae1f8bb88d4270c794259302d265301132ce375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 12:24:52 +0200 Subject: [PATCH 143/173] Fix taiko Difficulty Adjust scroll speed value getting truncated on extended mod icon information --- osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 296342ed97..b06d1fe5ac 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -28,13 +28,13 @@ namespace osu.Game.Rulesets.Taiko.Mods if (!IsExactlyOneSettingChanged(ScrollSpeed, OverallDifficulty, DrainRate)) return string.Empty; - if (!ScrollSpeed.IsDefault) return format("SC", ScrollSpeed); - if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); - if (!DrainRate.IsDefault) return format("HP", DrainRate); + if (!ScrollSpeed.IsDefault) return format("SC", ScrollSpeed, 2); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty, 1); + if (!DrainRate.IsDefault) return format("HP", DrainRate, 1); return string.Empty; - string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; + string format(string acronym, DifficultyBindable bindable, int digits) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(digits)}"; } } From a95987aadfbe876ec0fddfea8846ecc3007fa547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 12:54:16 +0200 Subject: [PATCH 144/173] Move external edit overlay to more proper namespace --- osu.Game/Overlays/{ => SkinEditor}/ExternalEditOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename osu.Game/Overlays/{ => SkinEditor}/ExternalEditOverlay.cs (99%) diff --git a/osu.Game/Overlays/ExternalEditOverlay.cs b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs similarity index 99% rename from osu.Game/Overlays/ExternalEditOverlay.cs rename to osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs index e9b3590626..89b36476ec 100644 --- a/osu.Game/Overlays/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs @@ -29,7 +29,7 @@ using osu.Game.Skinning; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays +namespace osu.Game.Overlays.SkinEditor { public partial class ExternalEditOverlay : OsuFocusedOverlayContainer { From bccdf4213308a05d23d6606e3c0e9fd0b9e71ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 12:59:37 +0200 Subject: [PATCH 145/173] Eliminate weird parameter passing --- .../Overlays/SkinEditor/ExternalEditOverlay.cs | 18 ++++++------------ osu.Game/Overlays/SkinEditor/SkinEditor.cs | 2 +- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs index 89b36476ec..edf7db2f7d 100644 --- a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs @@ -5,7 +5,6 @@ using System; using System.IO; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -42,10 +41,10 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private GameHost gameHost { get; set; } = null!; - private ExternalEditOperation? editOperation; + [Resolved] + private SkinManager skinManager { get; set; } = null!; - private Bindable? skinBindable; - private SkinManager? skinManager; + private ExternalEditOperation? editOperation; protected override bool DimMainContent => false; @@ -96,7 +95,7 @@ namespace osu.Game.Overlays.SkinEditor }; } - public async Task Begin(SkinInfo skinInfo, Bindable skinBindable, SkinManager skinManager) + public async Task Begin(SkinInfo skinInfo) { Show(); showSpinner("Mounting external skin..."); @@ -115,9 +114,6 @@ namespace osu.Game.Overlays.SkinEditor Hide(); } - this.skinBindable = skinBindable; - this.skinManager = skinManager; - Schedule(() => { flow.Children = new Drawable[] @@ -194,12 +190,12 @@ namespace osu.Game.Overlays.SkinEditor Schedule(() => { - var oldSkin = skinBindable!.Value; + var oldSkin = skinManager.CurrentSkin!.Value; var newSkinInfo = oldSkin.SkinInfo.PerformRead(s => s); // Create a new skin instance to ensure the skin is reloaded // If there's a better way to reload the skin, this should be replaced with it. - skinBindable.Value = newSkinInfo.CreateInstance(skinManager!); + skinManager.CurrentSkin.Value = newSkinInfo.CreateInstance(skinManager!); oldSkin.Dispose(); @@ -218,8 +214,6 @@ namespace osu.Game.Overlays.SkinEditor { // Set everything to a clean state editOperation = null; - skinManager = null; - skinBindable = null; flow.Children = Array.Empty(); }); } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 3aade5edc4..22ef80be84 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -284,7 +284,7 @@ namespace osu.Game.Overlays.SkinEditor { var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); - await externalEditOverlay!.Begin(skin, currentSkin, skins).ConfigureAwait(false); + await externalEditOverlay!.Begin(skin).ConfigureAwait(false); } public bool OnPressed(KeyBindingPressEvent e) From b82bf228aba313a39bc92039b6fd00e390c3bac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 13:23:49 +0200 Subject: [PATCH 146/173] Use better method of disallowing skin changes during external edit --- osu.Game/OsuGame.cs | 17 ++--------------- .../Overlays/Settings/Sections/SkinSection.cs | 15 ++++++++++++--- .../Overlays/SkinEditor/ExternalEditOverlay.cs | 12 +++++++++++- osu.Game/Skinning/SkinManager.cs | 4 ++++ 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 831a24bbd5..9e524878dc 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -138,8 +138,6 @@ namespace osu.Game private SkinEditorOverlay skinEditor; - private ExternalEditOverlay externalEditOverlay; - private Container overlayContent; private Container rightFloatingOverlayContent; @@ -1227,7 +1225,7 @@ namespace osu.Game loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true); - loadComponentSingleFile(externalEditOverlay = new ExternalEditOverlay(), overlayContent.Add, true); + loadComponentSingleFile(new ExternalEditOverlay(), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { @@ -1278,17 +1276,6 @@ namespace osu.Game }; } - Settings.State.ValueChanged += state => - { - if (state.NewValue == Visibility.Hidden) - return; - - if (externalEditOverlay.State.Value == Visibility.Visible) - { - Scheduler.Add(() => Settings.Hide()); - } - }; - // ensure only one of these overlays are open at once. var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; @@ -1600,7 +1587,7 @@ namespace osu.Game // Don't allow random skin selection while in the skin editor. // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path. // If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow. - if (skinEditor.State.Value == Visibility.Visible || externalEditOverlay.State.Value == Visibility.Visible) + if (skinEditor.State.Value == Visibility.Visible) return false; SkinManager.SelectRandomSkin(); diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 84767c8619..eef8030121 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -175,9 +175,12 @@ namespace osu.Game.Overlays.Settings.Sections base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + currentSkin.BindValueChanged(_ => updateState()); + currentSkin.BindDisabledChanged(_ => updateState(), true); } + private void updateState() => Enabled.Value = !currentSkin.Disabled && currentSkin.Value.SkinInfo.PerformRead(s => !s.Protected); + public Popover GetPopover() { return new RenameSkinPopover(); @@ -203,9 +206,12 @@ namespace osu.Game.Overlays.Settings.Sections base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + currentSkin.BindValueChanged(_ => updateState()); + currentSkin.BindDisabledChanged(_ => updateState(), true); } + private void updateState() => Enabled.Value = !currentSkin.Disabled && currentSkin.Value.SkinInfo.PerformRead(s => !s.Protected); + private void export() { try @@ -241,9 +247,12 @@ namespace osu.Game.Overlays.Settings.Sections base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + currentSkin.BindValueChanged(_ => updateState()); + currentSkin.BindDisabledChanged(_ => updateState(), true); } + private void updateState() => Enabled.Value = !currentSkin.Disabled && currentSkin.Value.SkinInfo.PerformRead(s => !s.Protected); + private void delete() { dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value)); diff --git a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs index edf7db2f7d..d8dc01362c 100644 --- a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs @@ -99,6 +99,7 @@ namespace osu.Game.Overlays.SkinEditor { Show(); showSpinner("Mounting external skin..."); + setGlobalSkinDisabled(true); await Task.Delay(500).ConfigureAwait(true); @@ -111,6 +112,7 @@ namespace osu.Game.Overlays.SkinEditor Logger.Log($"Failed to initialize external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); Schedule(() => showSpinner("Export failed!")); await Task.Delay(1000).ConfigureAwait(true); + setGlobalSkinDisabled(false); Hide(); } @@ -186,6 +188,7 @@ namespace osu.Game.Overlays.SkinEditor showSpinner("Import failed!"); await Task.Delay(1000).ConfigureAwait(true); Hide(); + setGlobalSkinDisabled(false); } Schedule(() => @@ -195,7 +198,8 @@ namespace osu.Game.Overlays.SkinEditor // Create a new skin instance to ensure the skin is reloaded // If there's a better way to reload the skin, this should be replaced with it. - skinManager.CurrentSkin.Value = newSkinInfo.CreateInstance(skinManager!); + setGlobalSkinDisabled(false); + skinManager.CurrentSkin.Value = newSkinInfo.CreateInstance(skinManager); oldSkin.Dispose(); @@ -203,6 +207,12 @@ namespace osu.Game.Overlays.SkinEditor }); } + private void setGlobalSkinDisabled(bool disabled) + { + skinManager.CurrentSkin.Disabled = disabled; + skinManager.CurrentSkinInfo.Disabled = disabled; + } + protected override void PopIn() { this.FadeIn(transition_duration, Easing.OutQuint); diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 9018c2e2c3..825d2f59c5 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -131,6 +131,10 @@ namespace osu.Game.Skinning { Realm.Run(r => { + // can be the case when the current skin is externally mounted for editing + if (CurrentSkinInfo.Disabled) + return; + // Required local for iOS. Will cause runtime crash if inlined. Guid currentSkinId = CurrentSkinInfo.Value.ID; From f63dc2dcea2cf0bb8ed380b404b266bf1cd7b966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 13:25:42 +0200 Subject: [PATCH 147/173] Ensure pending changes are saved before initiating external edit operation --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 22ef80be84..140c011e3c 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -282,6 +282,8 @@ namespace osu.Game.Overlays.SkinEditor private async Task editExternally() { + Save(); + var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); await externalEditOverlay!.Begin(skin).ConfigureAwait(false); From 17cfa7fcf3dd53e346e50028b2e1300f574264ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 13:35:53 +0200 Subject: [PATCH 148/173] Prevent hiding skin editor during external edit operation --- .../Overlays/SkinEditor/ExternalEditOverlay.cs | 16 +++++++++++++++- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 6 +++++- .../Overlays/SkinEditor/SkinEditorOverlay.cs | 8 ++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs index d8dc01362c..cbf85c6f2b 100644 --- a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.IO; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -45,6 +46,7 @@ namespace osu.Game.Overlays.SkinEditor private SkinManager skinManager { get; set; } = null!; private ExternalEditOperation? editOperation; + private TaskCompletionSource? taskCompletionSource; protected override bool DimMainContent => false; @@ -95,8 +97,11 @@ namespace osu.Game.Overlays.SkinEditor }; } - public async Task Begin(SkinInfo skinInfo) + public async Task Begin(SkinInfo skinInfo) { + if (taskCompletionSource != null) + throw new InvalidOperationException("Cannot start multiple concurrent external edits!"); + Show(); showSpinner("Mounting external skin..."); setGlobalSkinDisabled(true); @@ -114,6 +119,7 @@ namespace osu.Game.Overlays.SkinEditor await Task.Delay(1000).ConfigureAwait(true); setGlobalSkinDisabled(false); Hide(); + return Task.FromException(ex); } Schedule(() => @@ -163,6 +169,7 @@ namespace osu.Game.Overlays.SkinEditor b.Enabled.Value = true; openDirectory(); }, 1000); + return (taskCompletionSource = new TaskCompletionSource()).Task; } private void openDirectory() @@ -175,6 +182,8 @@ namespace osu.Game.Overlays.SkinEditor private async Task finish() { + Debug.Assert(taskCompletionSource != null); + showSpinner("Cleaning up..."); await Task.Delay(500).ConfigureAwait(true); @@ -189,6 +198,9 @@ namespace osu.Game.Overlays.SkinEditor await Task.Delay(1000).ConfigureAwait(true); Hide(); setGlobalSkinDisabled(false); + taskCompletionSource.SetException(ex); + taskCompletionSource = null; + return; } Schedule(() => @@ -205,6 +217,8 @@ namespace osu.Game.Overlays.SkinEditor Hide(); }); + taskCompletionSource.SetResult(); + taskCompletionSource = null; } private void setGlobalSkinDisabled(bool disabled) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 140c011e3c..f4a1bb7562 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -48,6 +48,8 @@ namespace osu.Game.Overlays.SkinEditor public readonly BindableList SelectedComponents = new BindableList(); + public bool ExternalEditInProgress => externalEditOperation != null && !externalEditOperation.IsCompleted; + protected override bool StartHidden => true; private Drawable? targetScreen; @@ -107,6 +109,8 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private ExternalEditOverlay? externalEditOverlay { get; set; } + private Task? externalEditOperation; + public SkinEditor() { } @@ -286,7 +290,7 @@ namespace osu.Game.Overlays.SkinEditor var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); - await externalEditOverlay!.Begin(skin).ConfigureAwait(false); + externalEditOperation = await externalEditOverlay!.Begin(skin).ConfigureAwait(false); } public bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 571f99bd08..344dcc0d66 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -334,6 +334,14 @@ namespace osu.Game.Overlays.SkinEditor leasedBeatmapSkins = null; } + public new void ToggleVisibility() + { + if (skinEditor?.ExternalEditInProgress == true) + return; + + base.ToggleVisibility(); + } + private partial class EndlessPlayer : ReplayPlayer { protected override UserActivity? InitialActivity => null; From 5fb044956945915ef28054a04bcc1ceae8a9b9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 13:47:13 +0200 Subject: [PATCH 149/173] Fix error code paths just completely falling apart You can't hide a drawable outside of update thread. --- osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs index cbf85c6f2b..e4ac157936 100644 --- a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs @@ -115,10 +115,9 @@ namespace osu.Game.Overlays.SkinEditor catch (Exception ex) { Logger.Log($"Failed to initialize external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); - Schedule(() => showSpinner("Export failed!")); - await Task.Delay(1000).ConfigureAwait(true); setGlobalSkinDisabled(false); - Hide(); + Schedule(() => showSpinner("Export failed!")); + Scheduler.AddDelayed(Hide, 1000); return Task.FromException(ex); } @@ -195,8 +194,7 @@ namespace osu.Game.Overlays.SkinEditor { Logger.Log($"Failed to finish external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); showSpinner("Import failed!"); - await Task.Delay(1000).ConfigureAwait(true); - Hide(); + Scheduler.AddDelayed(Hide, 1000); setGlobalSkinDisabled(false); taskCompletionSource.SetException(ex); taskCompletionSource = null; From 2e0e7ff3c2c21c8718fae9ffcf954f5fb9b10e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 30 Jun 2025 14:13:39 +0200 Subject: [PATCH 150/173] Fix dodgy threading The fact that the stuff "just worked" previously due to one load-bearing detach in a random location is really scary because a lot of this was just not written the way it is supposed to be. --- osu.Game/Database/RealmAccess.cs | 38 +++++++++++++++++++ .../Database/RealmArchiveModelImporter.cs | 4 +- osu.Game/Database/RealmObjectExtensions.cs | 2 + osu.Game/Skinning/SkinImporter.cs | 33 ++++++++-------- 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 49bde7c505..59cbfcb1e3 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -543,6 +543,44 @@ namespace osu.Game.Database return writeTask; } + /// + /// Write changes to realm asynchronously, guaranteeing order of execution. + /// + /// The work to run. + public Task WriteAsync(Func action) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + + // Required to ensure the write is tracked and accounted for before disposal. + // Can potentially be avoided if we have a need to do so in the future. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); + + // CountdownEvent will fail if already at zero. + if (!pendingAsyncWrites.TryAddCount()) + pendingAsyncWrites.Reset(1); + + // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. + // Adding a forced Task.Run resolves this. + var writeTask = Task.Run(async () => + { + T result; + total_writes_async.Value++; + + // Not attempting to use Realm.GetInstanceAsync as there's seemingly no benefit to us (for now) and it adds complexity due to locking + // concerns in getRealmInstance(). On a quick check, it looks to be more suited to cases where realm is connecting to an online sync + // server, which we don't use. May want to report upstream or revisit in the future. + using (var realm = getRealmInstance()) + // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). + result = await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); + + pendingAsyncWrites.Signal(); + return result; + }); + + return writeTask; + } + /// /// Subscribe to a realm collection and begin watching for asynchronous changes. /// diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index 6f613267d6..a3cdc2dc77 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -205,9 +205,7 @@ namespace osu.Game.Database Directory.CreateDirectory(mountedPath); - // Detach files from the model to avoid realm contention when copying to the external location. - // This is safe as we are not modifying the model in any way. - foreach (var realmFile in model.Files.Detach()) + foreach (var realmFile in model.Files) { string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath()); string destinationPath = Path.Join(mountedPath, realmFile.Filename); diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 538ac1dff7..d43f90c292 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -14,6 +14,7 @@ using osu.Game.Input.Bindings; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Skinning; using Realms; namespace osu.Game.Database @@ -177,6 +178,7 @@ namespace osu.Game.Database c.CreateMap(); c.CreateMap(); c.CreateMap(); + c.CreateMap(); } /// diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index e1bdcaff0c..382a7b56c2 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -53,12 +53,11 @@ namespace osu.Game.Skinning /// The to update the with /// The to update /// - public override Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) + public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) { - var skinInfoLive = original.ToLive(Realm); - - skinInfoLive.PerformWrite(skinInfo => + return await Realm.WriteAsync?>(r => { + var skinInfo = r.Find(original.ID)!; skinInfo.Files.Clear(); string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray(); @@ -67,28 +66,28 @@ namespace osu.Game.Skinning { using var stream = File.OpenRead(Path.Combine(task.Path, file)); - modelManager.AddFile(original, stream, file); + modelManager.AddFile(skinInfo, stream, file, r); } string skinIniPath = Path.Combine(task.Path, "skin.ini"); - if (!File.Exists(skinIniPath)) - return; - - using (var stream = File.OpenRead(skinIniPath)) - using (var lineReader = new LineBufferedReader(stream)) + if (File.Exists(skinIniPath)) { - var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader); + using (var stream = File.OpenRead(skinIniPath)) + using (var lineReader = new LineBufferedReader(stream)) + { + var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader); - if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name)) - skinInfo.Name = decodedSkinIni.SkinInfo.Name; + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name)) + skinInfo.Name = decodedSkinIni.SkinInfo.Name; - if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator)) - skinInfo.Creator = decodedSkinIni.SkinInfo.Creator; + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator)) + skinInfo.Creator = decodedSkinIni.SkinInfo.Creator; + } } - }); - return Task.FromResult(skinInfoLive)!; + return skinInfo.ToLive(Realm); + }).ConfigureAwait(false); } protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) From ccc1a7ae6f01df3ac2db00d4c547d09a987fe2d3 Mon Sep 17 00:00:00 2001 From: marvin Date: Mon, 30 Jun 2025 18:43:37 +0200 Subject: [PATCH 151/173] Change icon colour of BeatDivisorControl to be light --- osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 5386b39190..5883b6d89d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -375,7 +375,8 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - IconColour = Color4.Black; + IconColour = colours.GrayB; + IconHoverColour = Color4.White; HoverColour = colours.Gray7; FlashColour = colours.Gray9; } From 32d478e19cec9c1989ee3a46e89aeeaea8e4180e Mon Sep 17 00:00:00 2001 From: Chris Ehmann Date: Mon, 30 Jun 2025 18:55:27 -0700 Subject: [PATCH 152/173] Use floored star rating in BeatmapCarouselFilterGrouping and AdvancedStats This commit changes BeatmapCarouselFilterGrouping to now use floored star rating when determining which group a beatmap belongs to, to be consistent with changes introduced here: https://github.com/ppy/osu/pull/33679. The AdvancedStats section of the original song select is also updated to show the floored star rating (rather than rounded). --- osu.Game/Screens/Select/Details/AdvancedStats.cs | 2 +- osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index b7086d2416..152398dee3 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -251,7 +251,7 @@ namespace osu.Game.Screens.Select.Details if (normalDifficulty == null || moddedDifficulty == null) return; - starDifficulty.Value = ((float)normalDifficulty.Value.Stars, (float)moddedDifficulty.Value.Stars); + starDifficulty.Value = ((float)normalDifficulty.Value.Stars.FloorToDecimalDigits(2), (float)moddedDifficulty.Value.Stars.FloorToDecimalDigits(2)); }), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); }); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index c68f377fbb..d9c2571bfb 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -11,6 +11,7 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; +using osu.Game.Utils; namespace osu.Game.Screens.SelectV2 { @@ -189,7 +190,7 @@ namespace osu.Game.Screens.SelectV2 }, items); case GroupMode.Difficulty: - return getGroupsBy(b => defineGroupByStars(b.StarRating), items); + return getGroupsBy(b => defineGroupByStars(b.StarRating.FloorToDecimalDigits(2)), items); case GroupMode.Length: return getGroupsBy(b => From 8ae8d847d504c542c2d398a2c0428961d089858f Mon Sep 17 00:00:00 2001 From: Chris Ehmann Date: Mon, 30 Jun 2025 19:52:40 -0700 Subject: [PATCH 153/173] Update TestGroupingByDifficulty to account for floored star rating --- .../Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index 7f34d7a901..29b4955d02 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -263,8 +263,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 var results = await runGrouping(GroupMode.Difficulty, beatmapSets); assertGroup(results, 0, "Below 1 Star", new[] { beatmapBelow1 }, ref total); - assertGroup(results, 1, "1 Star", new[] { beatmapAbove1 }, ref total); - assertGroup(results, 2, "2 Stars", new[] { beatmapAlmost2, beatmap2, beatmapAbove2 }, ref total); + assertGroup(results, 1, "1 Star", new[] { beatmapAbove1, beatmapAlmost2 }, ref total); + assertGroup(results, 2, "2 Stars", new[] { beatmap2, beatmapAbove2 }, ref total); assertGroup(results, 3, "7 Stars", new[] { beatmap7 }, ref total); assertTotal(results, total); } From 87357f8ba425378879b8e923298a7db0fa64cf47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 1 Jul 2025 09:25:29 +0200 Subject: [PATCH 154/173] Apply flooring directly rather than flooring and then rounding (down, anyway) Just a bit weird. Especially so that the function isn't even generic, it's called `defineGroupByStars()`, so flooring locally is 200% warranted. --- osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index d9c2571bfb..cef08370f7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -11,7 +11,6 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; -using osu.Game.Utils; namespace osu.Game.Screens.SelectV2 { @@ -190,7 +189,7 @@ namespace osu.Game.Screens.SelectV2 }, items); case GroupMode.Difficulty: - return getGroupsBy(b => defineGroupByStars(b.StarRating.FloorToDecimalDigits(2)), items); + return getGroupsBy(b => defineGroupByStars(b.StarRating), items); case GroupMode.Length: return getGroupsBy(b => @@ -324,7 +323,7 @@ namespace osu.Game.Screens.SelectV2 private GroupDefinition defineGroupByStars(double stars) { - int starInt = (int)Math.Round(stars, 2); + int starInt = (int)stars; var starDifficulty = new StarDifficulty(starInt, 0); if (starInt == 0) From 6084863aef9cc49f61f060755f1d5437509c1988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 1 Jul 2025 09:26:43 +0200 Subject: [PATCH 155/173] Leave inline comment for posterity --- osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index cef08370f7..772d4123c2 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -323,6 +323,7 @@ namespace osu.Game.Screens.SelectV2 private GroupDefinition defineGroupByStars(double stars) { + // truncation is intentional - compare `FormatUtils.FormatStarRating()` int starInt = (int)stars; var starDifficulty = new StarDifficulty(starInt, 0); From 654faa553a95f83bf6804f22995ca4728702d1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 1 Jul 2025 10:14:27 +0200 Subject: [PATCH 156/173] Fix very old lazer replays failing to decode See https://discord.com/channels/188630481301012481/1097318920991559880/1389240665061462047. I hope this was worth it. --- osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index cf6819b086..ec2b567a7b 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -286,7 +286,23 @@ namespace osu.Game.Scoring.Legacy // In mania, mouseX encodes the pressed keys in the lower 20 bits int mouseXParseLimit = currentRuleset.RulesetInfo.OnlineID == 3 ? (1 << 20) - 1 : Parsing.MAX_COORDINATE_VALUE; - int diff = Parsing.ParseInt(split[0]); + // the legacy replay format as defined by stable expects frame delta times + // ('delta time' here meaning the amount of time between consecutive frames) + // to be integral and does not allow fractional values. + // one particular reason why this matters is that integral deltas + // avoid nasty floating point traps like accumulation error from summation or round-off error. + // however, there was a period in lazer's lifetime wherein lazer emitted replays + // with fractional (float) frame deltas, up until https://github.com/ppy/osu/pull/12583. + // despite the fact that gameplay mechanics changed multiple times since + // and the replay isn't going to play back anywhere near accurately anyway, + // no mistakes are ever forgiven, thus this attempts to parse the delta as an integer once, + // and if that fails, tries again as float. + // notably this cannot just be `(int)Parsing.ParseFloat(split[0])`, because that can lose information + // (`float` numbers have 24 bits of significand precision, which is not enough to accurately represent every possible value of `int`). + int diff; + if (!int.TryParse(split[0], out diff)) + diff = (int)Math.Round(Parsing.ParseFloat(split[0])); + float mouseX = Parsing.ParseFloat(split[1], mouseXParseLimit); float mouseY = Parsing.ParseFloat(split[2], Parsing.MAX_COORDINATE_VALUE); From 6f10aa5d9ce3e1b6f35e572d166889e367b8c1a8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 18:05:06 +0900 Subject: [PATCH 157/173] Default to Song Select V2 --- osu.Game/Screens/Menu/MainMenu.cs | 102 ++---------------------------- 1 file changed, 7 insertions(+), 95 deletions(-) diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index d87727b797..bc3bcbd800 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -14,13 +14,11 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; -using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -41,12 +39,10 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; -using osu.Game.Screens.Select; using osu.Game.Screens.SelectV2; using osu.Game.Seasonal; using osuTK; using osuTK.Graphics; -using osuTK.Input; namespace osu.Game.Screens.Menu { @@ -93,8 +89,6 @@ namespace osu.Game.Screens.Menu IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; private readonly Bindable samplePlaybackDisabled = new Bindable(); - private InputManager inputManager; - protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(); protected override bool PlayExitSound => false; @@ -121,6 +115,9 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private SkinEditorOverlay skinEditor { get; set; } + [CanBeNull] + private IDisposable logoProxy; + [BackgroundDependencyLoader(true)] private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, OsuConfigManager config, SessionStatics statics, AudioManager audio) { @@ -160,7 +157,7 @@ namespace osu.Game.Screens.Menu { skinEditor?.Show(); }, - OnSolo = loadPreferredSongSelect, + OnSolo = loadSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), OnPlaylists = () => this.Push(new Playlists()), OnDailyChallenge = room => @@ -241,19 +238,12 @@ namespace osu.Game.Screens.Menu Buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); reappearSampleSwoosh = audio.Samples.Get(@"Menu/reappear-swoosh"); - loadSongSelectV2Samples(audio); - } - - protected override void Update() - { - base.Update(); - updateSongSelectV2HoldState(); } protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); + GetContainingInputManager(); } public void ReturnToOsuLogo() => Buttons.State = ButtonSystemState.Initial; @@ -465,7 +455,7 @@ namespace osu.Game.Screens.Menu Beatmap.Value = beatmap; Ruleset.Value = ruleset; - Schedule(loadPreferredSongSelect); + Schedule(loadSongSelect); } public bool OnPressed(KeyBindingPressEvent e) @@ -489,85 +479,7 @@ namespace osu.Game.Screens.Menu { } - #region TEMPORARY: Song Select v2 easter egg - - private const double required_hold_time = 500; - - private double holdTime; - private bool ssv2Expanded; - private IDisposable ssv2Duck; - private Sample ssv2Sample; - - [CanBeNull] - private IDisposable logoProxy; - - private void loadPreferredSongSelect() - { - if (holdTime >= required_hold_time) - { - ssv2Sample?.Play(); - this.Push(new SoloSongSelect()); - } - else - this.Push(new PlaySongSelect()); - } - - private void loadSongSelectV2Samples(AudioManager audio) - { - ssv2Sample = audio.Samples.Get(@"UI/bss-complete"); - } - - private void updateSongSelectV2HoldState() - { - bool isValidHoverState = Buttons.State == ButtonSystemState.Play && - inputManager.CurrentState.Mouse.IsPressed(MouseButton.Left) && - inputManager.HoveredDrawables.Any(h => h is OsuLogo || (h is MainMenuButton b && b.TriggerKeys.Contains(Key.P))); - - if (isValidHoverState) - { - holdTime += Time.Elapsed; - - if (holdTime >= required_hold_time && !ssv2Expanded) - { - var transformTarget = Game.ChildrenOfType().First(); - - transformTarget.Anchor = Anchor.Centre; - transformTarget.Origin = Anchor.Centre; - - transformTarget.ScaleTo(1.2f, 5000, Easing.OutPow10) - .RotateTo(2, 5000, Easing.OutPow10) - .FadeColour(Color4.BlueViolet, 10000, Easing.OutPow10); - - ssv2Duck = musicController.Duck(new DuckParameters - { - DuckDuration = 2000, - DuckVolumeTo = 0.8f, - DuckCutoffTo = 500, - DuckEasing = Easing.OutQuint, - RestoreDuration = 200, - RestoreEasing = Easing.OutQuint - }); - - ssv2Expanded = true; - } - } - else if (holdTime > 0) - { - var transformTarget = Game.ChildrenOfType().FirstOrDefault(); - - transformTarget.ScaleTo(1, 500, Easing.OutQuint) - .RotateTo(0, 500, Easing.OutQuint) - .FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); - - ssv2Duck?.Dispose(); - ssv2Duck = null; - - ssv2Expanded = false; - holdTime = 0; - } - } - - #endregion + private void loadSongSelect() => this.Push(new SoloSongSelect()); private partial class MobileDisclaimerDialog : PopupDialog { From 1048bd9edebba44a29a8887cdaa2155cb6198b3a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 01:52:46 +0900 Subject: [PATCH 158/173] Remove Song Select v1 implementation and update auxiliary usages --- .../Overlays/FirstRunSetup/ScreenUIScale.cs | 13 +- .../Overlays/SkinEditor/SkinEditorOverlay.cs | 6 +- .../SkinEditor/SkinEditorSceneLibrary.cs | 5 +- osu.Game/Screens/Select/PlaySongSelect.cs | 166 ------------------ 4 files changed, 14 insertions(+), 176 deletions(-) delete mode 100644 osu.Game/Screens/Select/PlaySongSelect.cs diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index fc64408775..aec1859176 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -25,7 +25,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Visual; using osuTK; @@ -101,11 +101,14 @@ namespace osu.Game.Overlays.FirstRunSetup } } - private partial class NestedSongSelect : PlaySongSelect + private partial class NestedSongSelect : SoloSongSelect { - protected override bool ControlGlobalMusic => false; - public override bool? ApplyModTrackAdjustments => false; + + public NestedSongSelect() + { + ControlGlobalMusic = false; + } } private partial class UIScaleSlider : RoundedSliderBar @@ -148,7 +151,7 @@ namespace osu.Game.Overlays.FirstRunSetup [BackgroundDependencyLoader] private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { - Beatmap.Value = new DummyWorkingBeatmap(audio, textures); + Beatmap.Default = Beatmap.Value = new DummyWorkingBeatmap(audio, textures); Ruleset.Value = rulesets.AvailableRulesets.First(); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 571f99bd08..29158a3880 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -28,7 +28,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Users; using osu.Game.Utils; @@ -180,7 +180,7 @@ namespace osu.Game.Overlays.SkinEditor // the validity of the current game-wide beatmap + ruleset combination is enforced by song select. // if we're anywhere else, the state is unknown and may not make sense, so forcibly set something that does. - if (screen is not PlaySongSelect) + if (screen is not SoloSongSelect) ruleset.Value = beatmap.Value.BeatmapInfo.Ruleset; var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); @@ -194,7 +194,7 @@ namespace osu.Game.Overlays.SkinEditor if (replayGeneratingMod != null) screen.Push(new EndlessPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods))); - }, new[] { typeof(Player), typeof(PlaySongSelect) }); + }, new[] { typeof(Player), typeof(SoloSongSelect) }); } protected override void Update() diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index 5a283c0e8d..f8d5213622 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -12,8 +12,9 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Screens; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK; +using SongSelect = osu.Game.Screens.Select.SongSelect; namespace osu.Game.Overlays.SkinEditor { @@ -78,7 +79,7 @@ namespace osu.Game.Overlays.SkinEditor if (screen is SongSelect) return; - screen.Push(new PlaySongSelect()); + screen.Push(new SoloSongSelect()); }, new[] { typeof(SongSelect) }) }, new SceneButton diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs deleted file mode 100644 index 2f47243b50..0000000000 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ /dev/null @@ -1,166 +0,0 @@ -// 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.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Screens; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; -using osu.Game.Overlays; -using osu.Game.Overlays.Notifications; -using osu.Game.Rulesets.Mods; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Screens.Ranking; -using osu.Game.Users; -using osu.Game.Utils; -using osuTK.Input; - -namespace osu.Game.Screens.Select -{ - public partial class PlaySongSelect : SongSelect - { - private OsuScreen? playerLoader; - - [Resolved] - private INotificationOverlay? notifications { get; set; } - - public override bool AllowExternalScreenChange => true; - - public override MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(Func getBeatmap) => new MenuItem[] - { - new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => FinaliseSelection(getBeatmap())), - new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(getBeatmap())) - }; - - protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); - - private PlayBeatmapDetailArea playBeatmapDetailArea = null!; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BeatmapOptions.AddButton(ButtonSystemStrings.Edit.ToSentence(), @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); - - AddInternal(new SongSelectTouchInputDetector()); - } - - protected void PresentScore(ScoreInfo score) => - FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score))); - - protected override BeatmapDetailArea CreateBeatmapDetailArea() - { - playBeatmapDetailArea = new PlayBeatmapDetailArea - { - Leaderboard = - { - ScoreSelected = PresentScore - } - }; - - return playBeatmapDetailArea; - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - switch (e.Key) - { - case Key.Enter: - case Key.KeypadEnter: - // this is a special hard-coded case; we can't rely on OnPressed (of SongSelect) as GlobalActionContainer is - // matching with exact modifier consideration (so Ctrl+Enter would be ignored). - FinaliseSelection(); - return true; - } - - return base.OnKeyDown(e); - } - - private IReadOnlyList? modsAtGameplayStart; - - private ModAutoplay? getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); - - protected override bool OnStart() - { - if (playerLoader != null) return false; - - modsAtGameplayStart = Mods.Value.Select(m => m.DeepClone()).ToArray(); - - // Ctrl+Enter should start map with autoplay enabled. - if (GetContainingInputManager()?.CurrentState?.Keyboard.ControlPressed == true) - { - var autoInstance = getAutoplayMod(); - - if (autoInstance == null) - { - notifications?.Post(new SimpleNotification - { - Text = NotificationsStrings.NoAutoplayMod - }); - return false; - } - - var mods = Mods.Value.Append(autoInstance).ToArray(); - - if (!ModUtils.CheckCompatibleSet(mods, out var invalid)) - mods = mods.Except(invalid).Append(autoInstance).ToArray(); - - Mods.Value = mods; - } - - SampleConfirm?.Play(); - - this.Push(playerLoader = new PlayerLoader(createPlayer)); - return true; - - Player createPlayer() - { - Player player; - - var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); - - if (replayGeneratingMod != null) - { - player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)); - } - else - { - player = new SoloPlayer(); - } - - return player; - } - } - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - revertMods(); - } - - public override bool OnExiting(ScreenExitEvent e) - { - if (base.OnExiting(e)) - return true; - - revertMods(); - return false; - } - - private void revertMods() - { - if (playerLoader == null) return; - - Mods.Value = modsAtGameplayStart; - playerLoader = null; - } - } -} From 397b9b39945274402e600b29978216254c9c3b30 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 17:00:07 +0900 Subject: [PATCH 159/173] Update existing tests to work with Song Select v2 --- .../Background/TestSceneUserDimBackgrounds.cs | 12 +- .../Visual/Editing/TestSceneEditorSaving.cs | 4 +- .../Editing/TestSceneOpenEditorTimestamp.cs | 10 +- .../TestSceneBeatmapEditorNavigation.cs | 20 +- .../TestSceneButtonSystemNavigation.cs | 6 +- .../TestSceneChangeAndUseGameplayBindings.cs | 8 +- .../TestSceneMouseWheelVolumeAdjust.cs | 7 +- .../Navigation/TestScenePerformFromScreen.cs | 16 +- .../Navigation/TestScenePresentBeatmap.cs | 16 +- .../Navigation/TestScenePresentScore.cs | 17 +- .../Navigation/TestSceneScreenNavigation.cs | 241 +-- .../TestSceneSkinEditorNavigation.cs | 15 +- .../SongSelect/TestSceneBeatmapLeaderboard.cs | 548 ------- .../TestSceneBeatmapRecommendations.cs | 3 +- .../SongSelect/TestScenePlaySongSelect.cs | 1445 ----------------- .../TestSceneBeatmapLeaderboardWedge.cs | 224 ++- osu.Game/Graphics/Carousel/Carousel.cs | 4 +- .../SelectV2/BeatmapLeaderboardScore.cs | 57 +- osu.Game/Screens/SelectV2/FilterControl.cs | 2 +- osu.Game/Screens/SelectV2/SoloSongSelect.cs | 4 +- osu.Game/Screens/SelectV2/SongSelect.cs | 9 +- .../Tests/Visual/EditorSavingTestScene.cs | 11 +- 22 files changed, 456 insertions(+), 2223 deletions(-) delete mode 100644 osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs delete mode 100644 osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index eeaa68e2ee..58fb02c90c 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -32,7 +32,7 @@ using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Storyboards.Drawables; using osu.Game.Tests.Resources; using osuTK; @@ -325,7 +325,7 @@ namespace osu.Game.Tests.Visual.Background private void setupUserSettings() { AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen()); - AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmapInfo != null); + AddUntilStep("Song select has selection", () => songSelect.Carousel?.CurrentSelection != null); AddStep("Set default user settings", () => { SelectedMods.Value = new[] { new OsuModNoFail() }; @@ -340,7 +340,7 @@ namespace osu.Game.Tests.Visual.Background rulesets?.Dispose(); } - private partial class DummySongSelect : PlaySongSelect + private partial class DummySongSelect : SoloSongSelect { private FadeAccessibleBackground background; @@ -355,7 +355,7 @@ namespace osu.Game.Tests.Visual.Background public readonly Bindable DimLevel = new BindableDouble(); public readonly Bindable BlurLevel = new BindableDouble(); - public new BeatmapCarousel Carousel => base.Carousel; + public BeatmapCarousel Carousel => this.ChildrenOfType().SingleOrDefault(); [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -368,7 +368,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundDimmed() => background.CurrentColour == OsuColour.Gray(1f - background.CurrentDim); - public bool IsBackgroundUndimmed() => background.CurrentColour == Color4.White; + public bool IsBackgroundUndimmed() => background.CurrentColour == new Color4(0.9f, 0.9f, 0.9f, 1f); public bool IsUserBlurApplied() => Precision.AlmostEquals(background.CurrentBlur, new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR), 0.1f); @@ -376,7 +376,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundVisible() => background.CurrentAlpha == 1; - public bool IsBackgroundBlur() => Precision.AlmostEquals(background.CurrentBlur, new Vector2(BACKGROUND_BLUR), 0.1f); + public bool IsBackgroundBlur() => Precision.AlmostBigger(background.CurrentBlur.X, 0, 0.1f); public bool CheckBackgroundBlur(Vector2 expected) => Precision.AlmostEquals(background.CurrentBlur, expected, 0.1f); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 2e7b55ab49..7f40da5bab 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -15,7 +15,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -190,7 +190,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Set tags again", () => EditorBeatmap.BeatmapInfo.Metadata.Tags = tags_to_discard); AddStep("Exit editor", () => Editor.Exit()); - AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddAssert("Tags reverted correctly", () => Game.Beatmap.Value.BeatmapInfo.Metadata.Tags == tags_to_save); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs index 955ded97af..e3b79d4053 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs @@ -14,7 +14,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Editing @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.Editing () => Is.EqualTo(1)); AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); addStepClickLink("00:00:000 (1)", waitForSeek: false); AddUntilStep("received 'must be in edit'", @@ -151,12 +151,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Present beatmap", () => Game.PresentBeatmap(beatmapSet)); AddUntilStep("Wait for song select", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.BeatmapSetsLoaded + && Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect + && songSelect.CarouselItemsPresented ); AddStep("Switch ruleset", () => Game.Ruleset.Value = ruleset); AddStep("Open editor for ruleset", () => - ((PlaySongSelect)Game.ScreenStack.CurrentScreen) + ((SoloSongSelect)Game.ScreenStack.CurrentScreen) .Edit(beatmapSet.Beatmaps.Last(beatmap => beatmap.Ruleset.Name == ruleset.Name)) ); AddUntilStep("Wait for editor open", () => editor?.ReadyForUse == true); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index ee5b1797ed..c7499c98b5 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -26,8 +26,8 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osuTK.Input; @@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("exit", () => getEditor().Exit()); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.Beatmap.Value is DummyWorkingBeatmap); } @@ -171,7 +171,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("switch ruleset at song select", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddStep("open editor", () => ((SoloSongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); AddAssert("editor ruleset is osu!", () => Game.Ruleset.Value, () => Is.EqualTo(new OsuRuleset().RulesetInfo)); @@ -187,8 +187,8 @@ namespace osu.Game.Tests.Visual.Navigation }); AddAssert("gameplay ruleset is osu!", () => Game.Ruleset.Value, () => Is.EqualTo(new OsuRuleset().RulesetInfo)); - AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(SoloSongSelect).Yield())); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddAssert("previous ruleset restored", () => Game.Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); } @@ -289,8 +289,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("user request play", () => Game.MusicController.Play(requestedByUser: true)); AddUntilStep("music still stopped", () => !Game.MusicController.IsPlaying); - AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(SoloSongSelect).Yield())); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddUntilStep("wait for music playing", () => Game.MusicController.IsPlaying); AddStep("user request stop", () => Game.MusicController.Stop(requestedByUser: true)); @@ -352,13 +352,13 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); AddUntilStep("wait for song select", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.BeatmapSetsLoaded); + && Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect + && songSelect.CarouselItemsPresented); } private void openEditor() { - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddStep("open editor", () => ((SoloSongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs index 43b160250c..0ccfb5a4e3 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs @@ -5,7 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Visual.Navigation InputManager.Key(Key.P); }); - AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); } [Test] @@ -55,7 +55,7 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("state is play", () => buttons.State == ButtonSystemState.Play); AddStep("press P", () => InputManager.Key(Key.P)); - AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs index 3a3af43cb1..4f27d9b323 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs @@ -15,7 +15,7 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; @@ -54,10 +54,10 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); - AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for carousel load", () => songSelect.CarouselItemsPresented); AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); @@ -94,7 +94,7 @@ namespace osu.Game.Tests.Visual.Navigation .AsEnumerable() .First(k => k.RulesetName == "osu" && k.ActionInt == 0); - private Screens.Select.SongSelect songSelect => Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect; + private SoloSongSelect songSelect => Game.ScreenStack.CurrentScreen as SoloSongSelect; private Player player => Game.ScreenStack.CurrentScreen as Player; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs index 26a37fa211..0a4349d73f 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Framework.Extensions; using osu.Game.Configuration; using osu.Game.Screens.Play; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; @@ -83,9 +84,9 @@ namespace osu.Game.Tests.Visual.Navigation private void loadToPlayerNonBreakTime() { Player? player = null; - Screens.Select.SongSelect songSelect = null!; - PushAndConfirm(() => songSelect = new TestSceneScreenNavigation.TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + SoloSongSelect songSelect = null!; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 5fe4bb9340..04d7b15295 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -17,9 +17,9 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; -using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { @@ -44,17 +44,17 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelect() { - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); - AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); + AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(SoloSongSelect) })); AddAssert("did perform", () => actionPerformed); - AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); + AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); } [Test] public void TestPerformAtMenuFromSongSelect() { - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -69,8 +69,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("Press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen is PlayerLoader); - AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); - AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(SoloSongSelect) })); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddAssert("did perform", () => actionPerformed); } @@ -257,7 +257,7 @@ namespace osu.Game.Tests.Visual.Navigation private void importAndWaitForSongSelect() { AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddUntilStep("beatmap updated", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID == 241526); } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index f036b4b3ef..e7172cacbf 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -16,7 +16,7 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; namespace osu.Game.Tests.Visual.Navigation { @@ -81,11 +81,9 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(osuImport); var maniaImport = importBeatmap(2, new ManiaRuleset().RulesetInfo); - confirmBeatmapInSongSelect(maniaImport); presentAndConfirm(maniaImport); var catchImport = importBeatmap(3, new CatchRuleset().RulesetInfo); - confirmBeatmapInSongSelect(catchImport); presentAndConfirm(catchImport); // Ruleset is always changed. @@ -103,11 +101,9 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(osuImport); var maniaImport = importBeatmap(2, new ManiaRuleset().RulesetInfo); - confirmBeatmapInSongSelect(maniaImport); presentAndConfirm(maniaImport); var catchImport = importBeatmap(3, new CatchRuleset().RulesetInfo); - confirmBeatmapInSongSelect(catchImport); presentAndConfirm(catchImport); // force ruleset to osu!mania @@ -178,14 +174,14 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("wait for carousel loaded", () => { - var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; + var songSelect = (SoloSongSelect)Game.ScreenStack.CurrentScreen; return songSelect.ChildrenOfType().SingleOrDefault()?.IsLoaded == true; }); AddUntilStep("beatmap in song select", () => { - var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; - return songSelect.ChildrenOfType().Single().BeatmapSets.Any(b => b.MatchesOnlineID(getImport())); + var songSelect = (SoloSongSelect)Game.ScreenStack.CurrentScreen; + return songSelect.ChildrenOfType().Single().GetCarouselItems()!.Any(i => i.Model is BeatmapSetInfo bsi && bsi.MatchesOnlineID(getImport())); }); } @@ -193,7 +189,7 @@ namespace osu.Game.Tests.Visual.Navigation { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID, () => Is.EqualTo(getImport().OnlineID)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value, () => Is.EqualTo(getImport().Beatmaps.First().Ruleset)); } @@ -203,7 +199,7 @@ namespace osu.Game.Tests.Visual.Navigation Predicate pred = b => b.OnlineID == importedID * 1024 + 2; AddStep("present difficulty", () => Game.PresentBeatmap(getImport(), pred)); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(importedID * 1024 + 2)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.OnlineID, () => Is.EqualTo(expectedRulesetOnlineID ?? getImport().Beatmaps.First().Ruleset.OnlineID)); } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 2c2335de13..fa337a3ec2 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -18,7 +18,8 @@ using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; +using FilterControl = osu.Game.Screens.SelectV2.FilterControl; namespace osu.Game.Tests.Visual.Navigation { @@ -96,9 +97,9 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelectWithFilter([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); - AddStep("filter to nothing", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).FilterControl.CurrentTextSearch.Value = "fdsajkl;fgewq"); + AddStep("filter to nothing", () => ((SoloSongSelect)Game.ScreenStack.CurrentScreen).ChildrenOfType().Single().Search("fdsajkl;fgewq")); AddUntilStep("wait for no results", () => Beatmap.IsDefault); var firstImport = importScore(1, new CatchRuleset().RulesetInfo); @@ -109,7 +110,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelectWithConvertRulesetChange([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); AddStep("set convert to false", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); @@ -121,7 +122,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelect([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); var firstImport = importScore(1); presentAndConfirm(firstImport, type); @@ -134,7 +135,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelectDifferentRuleset([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); var firstImport = importScore(1); presentAndConfirm(firstImport, type); @@ -147,7 +148,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestPresentTwoImportsWithSameOnlineIDButDifferentHashes([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); var firstImport = importScore(1); presentAndConfirm(firstImport, type); @@ -160,7 +161,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestScoreRefetchIgnoresEmptyHash() { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); importScore(-1, hash: string.Empty); importScore(3, hash: @"deadbeef"); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 2a755b46b3..bcab3c7672 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -26,7 +26,6 @@ using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.Leaderboards; using osu.Game.Online.Notifications.WebSocket; using osu.Game.Online.Notifications.WebSocket.Events; using osu.Game.Overlays; @@ -49,20 +48,13 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Leaderboards; -using osu.Game.Screens.Select.Options; using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Resources; using osu.Game.Utils; using osuTK; using osuTK.Input; -using BeatmapCarousel = osu.Game.Screens.Select.BeatmapCarousel; -using CollectionDropdown = osu.Game.Collections.CollectionDropdown; -using FilterControl = osu.Game.Screens.Select.FilterControl; -using FooterButtonRandom = osu.Game.Screens.Select.FooterButtonRandom; namespace osu.Game.Tests.Visual.Navigation { @@ -146,62 +138,70 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithEscape() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; + ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); - AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddStep("Show mods overlay", () => + { + modSelect = songSelect!.ChildrenOfType().Single(); + modSelect.Show(); + }); + AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); pushEscape(); - AddAssert("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + AddAssert("Overlay was hidden", () => modSelect.State.Value == Visibility.Hidden); exitViaEscapeAndConfirm(); } [Test] public void TestEnterGameplayWhileFilteringToNoSelection() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - AddStep("force selection", () => + AddStep("force selection and change filter immediately", () => { - songSelect.FinaliseSelection(); - songSelect.FilterControl.CurrentTextSearch.Value = "test"; + InputManager.Key(Key.Enter); + songSelect.ChildrenOfType().Single().Search("test"); }); AddUntilStep("wait for player", () => !songSelect.IsCurrentScreen()); AddStep("return to song select", () => songSelect.MakeCurrent()); - AddUntilStep("wait for selection lost", () => songSelect.Beatmap.IsDefault); + AddUntilStep("selection not lost", () => !songSelect.Beatmap.IsDefault); + AddUntilStep("placeholder visible", () => songSelect.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); } [Test] public void TestSongSelectBackActionHandling() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + + AddUntilStep("wait for filter control", () => filterControlTextBox().IsLoaded); AddStep("set filter", () => filterControlTextBox().Current.Value = "test"); AddStep("press back", () => InputManager.Click(MouseButton.Button1)); - AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); + AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen, () => Is.EqualTo(songSelect)); AddAssert("filter cleared", () => string.IsNullOrEmpty(filterControlTextBox().Current.Value)); AddStep("set filter again", () => filterControlTextBox().Current.Value = "test"); AddStep("open collections dropdown", () => { - InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); + InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("press back once", () => InputManager.Click(MouseButton.Button1)); AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); AddAssert("collections dropdown closed", () => songSelect - .ChildrenOfType().Single() + .ChildrenOfType().Single() .ChildrenOfType.DropdownMenu>().Single().State == MenuState.Closed); AddStep("press back a second time", () => InputManager.Click(MouseButton.Button1)); @@ -210,17 +210,17 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back a third time", () => InputManager.Click(MouseButton.Button1)); ConfirmAtMainMenu(); - TextBox filterControlTextBox() => songSelect.ChildrenOfType().Single(); + FilterControl.SongSelectSearchTextBox filterControlTextBox() => songSelect.ChildrenOfType().Single(); } [Test] public void TestSongSelectRandomRewindButton() { Guid? originalSelection = null; - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("Add two beatmaps", () => { @@ -248,20 +248,30 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestSongSelectScrollHandling() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; double scrollPosition = 0; AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); AddUntilStep("wait for volume overlay to hide", () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Hidden)); - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - AddStep("store scroll position", () => scrollPosition = getCarouselScrollPosition()); + AddUntilStep("store scroll position", () => + { + double s = getCarouselScrollPosition(); + + // TODO: this logic can likely be removed when we fix https://github.com/ppy/osu/issues/33379 + if (scrollPosition == s) + return true; + + scrollPosition = s; + return false; + }); AddStep("move to left side", () => InputManager.MoveMouseTo( - songSelect.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + new Vector2(1))); + songSelect.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre)); AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); AddAssert("carousel didn't move", getCarouselScrollPosition, () => Is.EqualTo(scrollPosition)); @@ -277,7 +287,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); AddAssert("carousel moved", getCarouselScrollPosition, () => Is.Not.EqualTo(scrollPosition)); - double getCarouselScrollPosition() => Game.ChildrenOfType>().Single().Current; + double getCarouselScrollPosition() => Game.ChildrenOfType.CarouselScrollContainer>().Single().Current; } [Test] @@ -325,7 +335,7 @@ namespace osu.Game.Tests.Visual.Navigation }, 5); AddAssert("game volume decreased", () => Game.Dependencies.Get().Get(FrameworkSetting.VolumeUniversal), () => Is.LessThan(1)); - AddStep("move to carousel", () => InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single())); + AddStep("move to carousel", () => InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single())); AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); AddAssert("carousel moved", getCarouselScrollPosition, () => Is.Not.EqualTo(scrollPosition)); @@ -339,21 +349,21 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestOpenModSelectOverlayUsingAction() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); AddStep("Show mods overlay", () => InputManager.Key(Key.F1)); - AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + AddAssert("Overlay was shown", () => songSelect!.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] public void TestAttemptPlayBeatmapWrongHashFails() { - Screens.Select.SongSelect songSelect = null; + Screens.SelectV2.SongSelect songSelect = null; AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -384,11 +394,11 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestAttemptPlayBeatmapMissingFails() { - Screens.Select.SongSelect songSelect = null; + Screens.SelectV2.SongSelect songSelect = null; AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -418,9 +428,9 @@ namespace osu.Game.Tests.Visual.Navigation { Player player = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -461,9 +471,9 @@ namespace osu.Game.Tests.Visual.Navigation { Player player = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game).WaitSafely()); @@ -515,9 +525,9 @@ namespace osu.Game.Tests.Visual.Navigation { Player player = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game).WaitSafely()); @@ -558,9 +568,9 @@ namespace osu.Game.Tests.Visual.Navigation { Player player = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -663,7 +673,7 @@ namespace osu.Game.Tests.Visual.Navigation playToResults(); ScoreInfo score = null; - LeaderboardScore scorePanel = null; + BeatmapLeaderboardScore scorePanel = null; AddStep("get score", () => score = ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score); @@ -672,18 +682,11 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); AddStep("show local scores", - () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + () => Game.ChildrenOfType>().First().Current.Value = BeatmapLeaderboardScope.Local); - AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); + AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); - AddStep("open options", () => InputManager.Key(Key.F3)); - - AddStep("choose clear all scores", () => InputManager.Key(Key.Number4)); - - AddUntilStep("wait for dialog display", () => ((Drawable)Game.Dependencies.Get()).IsLoaded); - AddUntilStep("wait for dialog", () => Game.Dependencies.Get().CurrentDialog != null); - AddStep("confirm deletion", () => InputManager.Key(Key.Number1)); - AddUntilStep("wait for dialog dismissed", () => Game.Dependencies.Get().CurrentDialog == null); + AddStep("Clear all scores", () => Game.Dependencies.Get().Delete()); AddUntilStep("ensure score is pending deletion", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == true)); @@ -696,7 +699,7 @@ namespace osu.Game.Tests.Visual.Navigation playToResults(); ScoreInfo score = null; - LeaderboardScore scorePanel = null; + BeatmapLeaderboardScore scorePanel = null; AddStep("get score", () => score = ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score); @@ -705,9 +708,9 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); AddStep("show local scores", - () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + () => Game.ChildrenOfType>().First().Current.Value = BeatmapLeaderboardScope.Local); - AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); + AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); AddStep("right click panel", () => { @@ -718,7 +721,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("click delete", () => { var dropdownItem = Game - .ChildrenOfType().First() + .ChildrenOfType().First() .ChildrenOfType().First() .ChildrenOfType().First(i => i.Item.Text.ToString() == "Delete"); @@ -744,9 +747,9 @@ namespace osu.Game.Tests.Visual.Navigation IWorkingBeatmap beatmap() => Game.Beatmap.Value; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); @@ -777,9 +780,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestMenuMakesMusic() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice); @@ -791,7 +794,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPushSongSelectAndPressBackButtonImmediately() { - AddStep("push song select", () => Game.ScreenStack.Push(new TestPlaySongSelect())); + AddStep("push song select", () => Game.ScreenStack.Push(new SoloSongSelect())); AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); ConfirmAtMainMenu(); @@ -800,18 +803,23 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithClick() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; + ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); - AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddStep("Show mods overlay", () => + { + modSelect = songSelect!.ChildrenOfType().Single(); + modSelect.Show(); + }); + AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); AddStep("Move mouse to dimmed area", () => InputManager.MoveMouseTo(new Vector2( songSelect.ScreenSpaceDrawQuad.TopLeft.X + 1, songSelect.ScreenSpaceDrawQuad.TopLeft.Y + songSelect.ScreenSpaceDrawQuad.Height / 2))); AddStep("Click left mouse button", () => InputManager.Click(MouseButton.Left)); - AddUntilStep("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + AddUntilStep("Overlay was hidden", () => modSelect.State.Value == Visibility.Hidden); exitViaBackButtonAndConfirm(); } @@ -876,10 +884,18 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); - TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + SoloSongSelect songSelect = null; + ModSelectOverlay modSelect = null; - AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddStep("Show mods overlay", () => + { + modSelect = songSelect!.ChildrenOfType().Single(); + modSelect.Show(); + }); + AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); + + AddStep("Show mods overlay", () => modSelect.Show()); AddStep("Change ruleset to osu!taiko", () => { @@ -890,7 +906,7 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType().Single().Current.Value.OnlineID == 1); - AddAssert("Mods overlay still visible", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + AddAssert("Mods overlay still visible", () => modSelect.State.Value == Visibility.Visible); } [Test] @@ -900,10 +916,12 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + SoloSongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); - AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show()); + AddStep("Show options overlay", () => InputManager.Key(Key.F3)); + AddUntilStep("Options overlay visible", () => this.ChildrenOfType().SingleOrDefault()?.State.Value == Visibility.Visible); AddStep("Change ruleset to osu!taiko", () => { @@ -914,7 +932,7 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType().Single().Current.Value.OnlineID == 1); - AddAssert("Options overlay still visible", () => songSelect.BeatmapOptionsOverlay.State.Value == Visibility.Visible); + AddAssert("Options overlay still visible", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] @@ -1186,7 +1204,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitGameFromSongSelect() { - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); exitViaEscapeAndConfirm(); pushEscape(); // returns to osu! logo @@ -1258,10 +1276,10 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("close settings sidebar", () => InputManager.Key(Key.Escape)); - Screens.Select.SongSelect songSelect = null; + Screens.SelectV2.SongSelect songSelect = null; AddRepeatStep("go to solo", () => InputManager.Key(Key.P), 3); - AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect) != null); - AddUntilStep("wait for beatmap sets loaded", () => songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.SelectV2.SongSelect) != null); + AddUntilStep("wait for beatmap sets loaded", () => songSelect.CarouselItemsPresented); AddStep("switch to osu! ruleset", () => { @@ -1271,7 +1289,7 @@ namespace osu.Game.Tests.Visual.Navigation }); AddStep("touch beatmap wedge", () => { - var wedge = Game.ChildrenOfType().Single(); + var wedge = Game.ChildrenOfType().Single(); var touch = new Touch(TouchSource.Touch2, wedge.ScreenSpaceDrawQuad.Centre); InputManager.BeginTouch(touch); InputManager.EndTouch(touch); @@ -1287,7 +1305,7 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); AddStep("touch beatmap wedge", () => { - var wedge = Game.ChildrenOfType().Single(); + var wedge = Game.ChildrenOfType().Single(); var touch = new Touch(TouchSource.Touch2, wedge.ScreenSpaceDrawQuad.Centre); InputManager.BeginTouch(touch); InputManager.EndTouch(touch); @@ -1304,7 +1322,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("click beatmap wedge", () => { - InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); @@ -1315,7 +1333,7 @@ namespace osu.Game.Tests.Visual.Navigation { BeatmapSetInfo beatmapSet = null; - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("import beatmap", () => beatmapSet = BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); AddUntilStep("wait for selected", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet)); AddStep("select", () => InputManager.Key(Key.Enter)); @@ -1345,9 +1363,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectAndImmediatelyClickLogo() { - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -1376,9 +1394,9 @@ namespace osu.Game.Tests.Visual.Navigation { BeatmapSetInfo beatmap = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -1407,9 +1425,9 @@ namespace osu.Game.Tests.Visual.Navigation IWorkingBeatmap beatmap() => Game.Beatmap.Value; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -1447,12 +1465,5 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); ConfirmAtMainMenu(); } - - public partial class TestPlaySongSelect : PlaySongSelect - { - public ModSelectOverlay ModSelectOverlay => ModSelect; - - public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; - } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 622c85774a..fe76b74bcb 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -17,6 +17,7 @@ using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Online.API; using osu.Game.Beatmaps; +using osu.Game.Overlays.Mods; using osu.Game.Overlays.Settings; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets.Mods; @@ -26,17 +27,19 @@ using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Screens.SelectV2; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps.IO; using osuTK; using osuTK.Input; -using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { public partial class TestSceneSkinEditorNavigation : OsuGameTestScene { - private TestPlaySongSelect songSelect; + private SoloSongSelect songSelect; + private ModSelectOverlay modSelect => songSelect.ChildrenOfType().First(); + private SkinEditor skinEditor => Game.ChildrenOfType().FirstOrDefault(); [Test] @@ -331,10 +334,10 @@ namespace osu.Game.Tests.Visual.Navigation public void TestModOverlayClosesOnOpeningSkinEditor() { advanceToSongSelect(); - AddStep("open mod overlay", () => songSelect.ModSelectOverlay.Show()); + AddStep("open mod overlay", () => modSelect.Show()); openSkinEditor(); - AddUntilStep("mod overlay closed", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + AddUntilStep("mod overlay closed", () => modSelect.State.Value == Visibility.Hidden); } [Test] @@ -448,8 +451,8 @@ namespace osu.Game.Tests.Visual.Navigation private void advanceToSongSelect() { - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); } private void openSkinEditor() diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs deleted file mode 100644 index 44f64365f0..0000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ /dev/null @@ -1,548 +0,0 @@ -// 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.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Cursor; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Leaderboards; -using osu.Game.Overlays; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Scoring; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Leaderboards; -using osu.Game.Tests.Resources; -using osu.Game.Users; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.SongSelect -{ - public partial class TestSceneBeatmapLeaderboard : OsuManualInputManagerTestScene - { - private readonly FailableLeaderboard leaderboard; - - [Cached(typeof(IDialogOverlay))] - private readonly DialogOverlay dialogOverlay; - - private ScoreManager scoreManager = null!; - private RulesetStore rulesetStore = null!; - private BeatmapManager beatmapManager = null!; - private PlaySongSelect songSelect = null!; - - private LeaderboardManager leaderboardManager = null!; - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); - dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); - dependencies.CacheAs(songSelect = new PlaySongSelect()); - dependencies.Cache(leaderboardManager = new LeaderboardManager()); - - Dependencies.Cache(Realm); - - return dependencies; - } - - [BackgroundDependencyLoader] - private void load() - { - LoadComponent(songSelect); - LoadComponent(leaderboardManager); - } - - public TestSceneBeatmapLeaderboard() - { - Add(new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - dialogOverlay = new DialogOverlay - { - Depth = -1 - }, - leaderboard = new FailableLeaderboard - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = BeatmapLeaderboardScope.Global, - } - } - }); - } - - [Test] - public void TestLocalScoresDisplay() - { - BeatmapInfo beatmapInfo = null!; - - AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); - - AddStep(@"Set beatmap", () => - { - beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); - - leaderboard.BeatmapInfo = beatmapInfo; - }); - - clearScores(); - checkDisplayedCount(0); - - importMoreScores(() => beatmapInfo); - checkDisplayedCount(10); - - importMoreScores(() => beatmapInfo); - checkDisplayedCount(20); - - clearScores(); - checkDisplayedCount(0); - } - - [Test] - public void TestLocalScoresDisplayWorksWhenStartingOffline() - { - BeatmapInfo beatmapInfo = null!; - - AddStep("Log out", () => API.Logout()); - AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); - - AddStep(@"Set beatmap", () => - { - beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); - - leaderboard.BeatmapInfo = beatmapInfo; - }); - - clearScores(); - importMoreScores(() => beatmapInfo); - checkDisplayedCount(10); - } - - [Test] - public void TestLocalScoresDisplayOnBeatmapEdit() - { - BeatmapInfo beatmapInfo = null!; - string originalHash = string.Empty; - - AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); - - AddStep(@"Import beatmap", () => - { - beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); - - leaderboard.BeatmapInfo = beatmapInfo; - }); - - clearScores(); - checkDisplayedCount(0); - - AddStep(@"Perform initial save to guarantee stable hash", () => - { - IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap; - beatmapManager.Save(beatmapInfo, beatmap); - - originalHash = beatmapInfo.Hash; - }); - - importMoreScores(() => beatmapInfo); - - checkDisplayedCount(10); - checkStoredCount(10); - - AddStep(@"Save with changes", () => - { - IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap; - beatmap.Difficulty.ApproachRate = 12; - beatmapManager.Save(beatmapInfo, beatmap); - }); - - AddAssert("Hash changed", () => beatmapInfo.Hash, () => Is.Not.EqualTo(originalHash)); - checkDisplayedCount(0); - checkStoredCount(10); - - importMoreScores(() => beatmapInfo); - importMoreScores(() => beatmapInfo); - checkDisplayedCount(20); - checkStoredCount(30); - - AddStep(@"Revert changes", () => - { - IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap; - beatmap.Difficulty.ApproachRate = 8; - beatmapManager.Save(beatmapInfo, beatmap); - }); - - AddAssert("Hash restored", () => beatmapInfo.Hash, () => Is.EqualTo(originalHash)); - checkDisplayedCount(10); - checkStoredCount(30); - - clearScores(); - checkDisplayedCount(0); - checkStoredCount(0); - } - - [Test] - public void TestGlobalScoresDisplay() - { - AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Global); - AddStep(@"New Scores", () => leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()))); - AddStep(@"New Scores with teams", () => leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()).Select(s => - { - s.User.Team = new APITeam(); - return s; - }))); - } - - [Test] - public void TestPersonalBest() - { - AddStep(@"Show personal best", showPersonalBest); - AddStep("null personal best position", showPersonalBestWithNullPosition); - } - - [Test] - public void TestPlaceholderStates() - { - AddStep("ensure no scores displayed", () => leaderboard.SetScores(null)); - - AddStep(@"Network failure", () => leaderboard.SetErrorState(LeaderboardState.NetworkFailure)); - AddStep(@"No team", () => leaderboard.SetErrorState(LeaderboardState.NoTeam)); - AddStep(@"No supporter", () => leaderboard.SetErrorState(LeaderboardState.NotSupporter)); - AddStep(@"Not logged in", () => leaderboard.SetErrorState(LeaderboardState.NotLoggedIn)); - AddStep(@"Ruleset unavailable", () => leaderboard.SetErrorState(LeaderboardState.RulesetUnavailable)); - AddStep(@"Beatmap unavailable", () => leaderboard.SetErrorState(LeaderboardState.BeatmapUnavailable)); - AddStep(@"None selected", () => leaderboard.SetErrorState(LeaderboardState.NoneSelected)); - } - - [Test] - public void TestUseTheseModsDoesNotCopySystemMods() - { - AddStep(@"set scores", () => leaderboard.SetScores(leaderboard.Scores, new ScoreInfo - { - Position = 999, - Rank = ScoreRank.XH, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Ruleset = new OsuRuleset().RulesetInfo, - Mods = new Mod[] { new OsuModHidden(), new ModScoreV2(), }, - User = new APIUser - { - Id = 6602580, - Username = @"waaiiru", - CountryCode = CountryCode.ES, - } - })); - AddUntilStep("wait for scores", () => this.ChildrenOfType().Count(), () => Is.GreaterThan(0)); - AddStep("right click panel", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Right); - }); - AddStep("click use these mods", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); - AddAssert("song select received HD", () => songSelect.Mods.Value.Any(m => m is OsuModHidden)); - AddAssert("song select did not receive SV2", () => !songSelect.Mods.Value.Any(m => m is ModScoreV2)); - } - - private void showPersonalBestWithNullPosition() - { - leaderboard.SetScores(leaderboard.Scores, new ScoreInfo - { - Rank = ScoreRank.XH, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - Ruleset = new OsuRuleset().RulesetInfo, - User = new APIUser - { - Id = 6602580, - Username = @"waaiiru", - CountryCode = CountryCode.ES, - }, - }); - } - - private void showPersonalBest() - { - leaderboard.SetScores(leaderboard.Scores, new ScoreInfo - { - Position = 999, - Rank = ScoreRank.XH, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Ruleset = new OsuRuleset().RulesetInfo, - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - User = new APIUser - { - Id = 6602580, - Username = @"waaiiru", - CountryCode = CountryCode.ES, - } - }); - } - - private void importMoreScores(Func beatmapInfo) - { - AddStep(@"Import new scores", () => - { - foreach (var score in GenerateSampleScores(beatmapInfo())) - scoreManager.Import(score); - }); - } - - private void clearScores() - { - AddStep("Clear all scores", () => scoreManager.Delete()); - } - - private void checkDisplayedCount(int expected) => - AddUntilStep($"{expected} scores displayed", () => leaderboard.ChildrenOfType().Count(), () => Is.EqualTo(expected)); - - private void checkStoredCount(int expected) => - AddUntilStep($"Total scores stored is {expected}", () => Realm.Run(r => r.All().Count(s => !s.DeletePending)), () => Is.EqualTo(expected)); - - public static ScoreInfo[] GenerateSampleScores(BeatmapInfo beatmapInfo) - { - return new[] - { - new ScoreInfo - { - Rank = ScoreRank.XH, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now, - Mods = new Mod[] - { - new OsuModHidden(), - new OsuModFlashlight - { - FollowDelay = { Value = 200 }, - SizeMultiplier = { Value = 5 }, - }, - new OsuModDifficultyAdjust - { - CircleSize = { Value = 11 }, - ApproachRate = { Value = 10 }, - OverallDifficulty = { Value = 10 }, - DrainRate = { Value = 10 }, - ExtendedLimits = { Value = true } - } - }, - Ruleset = new OsuRuleset().RulesetInfo, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - User = new APIUser - { - Id = 6602580, - Username = @"waaiiru", - CountryCode = CountryCode.ES, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.X, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddSeconds(-30), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - User = new APIUser - { - Id = 4608074, - Username = @"Skycries", - CountryCode = CountryCode.BR, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.SH, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddSeconds(-70), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 1014222, - Username = @"eLy", - CountryCode = CountryCode.JP, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.S, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddMinutes(-40), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 1541390, - Username = @"Toukai", - CountryCode = CountryCode.CA, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.A, - Accuracy = 1, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddHours(-2), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 2243452, - Username = @"Satoruu", - CountryCode = CountryCode.VE, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.B, - Accuracy = 0.9826, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddHours(-25), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 2705430, - Username = @"Mooha", - CountryCode = CountryCode.FR, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.C, - Accuracy = 0.9654, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddHours(-50), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 7151382, - Username = @"Mayuri Hana", - CountryCode = CountryCode.TH, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.D, - Accuracy = 0.6025, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddHours(-72), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 2051389, - Username = @"FunOrange", - CountryCode = CountryCode.CA, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.D, - Accuracy = 0.5140, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddMonths(-10), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 6169483, - Username = @"-Hebel-", - CountryCode = CountryCode.MX, - }, - }, - new ScoreInfo - { - Rank = ScoreRank.D, - Accuracy = 0.4222, - MaxCombo = 244, - TotalScore = 1707827, - Date = DateTime.Now.AddYears(-2), - Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - BeatmapInfo = beatmapInfo, - BeatmapHash = beatmapInfo.Hash, - Ruleset = new OsuRuleset().RulesetInfo, - - User = new APIUser - { - Id = 6702666, - Username = @"prhtnsm", - CountryCode = CountryCode.DE, - }, - }, - }; - } - - private partial class FailableLeaderboard : BeatmapLeaderboard - { - public new void SetErrorState(LeaderboardState state) => base.SetErrorState(state); - public new void SetScores(IEnumerable? scores, ScoreInfo? userScore = null) => base.SetScores(scores, userScore); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index d459eac3c2..832e8fc90f 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -23,6 +23,7 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osu.Game.Users; using osu.Game.Utils; @@ -248,7 +249,7 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect select && select.CarouselItemsPresented); AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(getImport().Beatmaps[expectedDiff - 1].OnlineID)); } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs deleted file mode 100644 index 9dc6bc8a33..0000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ /dev/null @@ -1,1445 +0,0 @@ -// 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.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input; -using osu.Framework.Platform; -using osu.Framework.Screens; -using osu.Framework.Testing; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Database; -using osu.Game.Extensions; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Chat; -using osu.Game.Overlays; -using osu.Game.Overlays.Dialog; -using osu.Game.Overlays.Mods; -using osu.Game.Overlays.Notifications; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mania.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; -using osu.Game.Screens.Select.Filter; -using osu.Game.Tests.Resources; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.SongSelect -{ - [TestFixture] - public partial class TestScenePlaySongSelect : ScreenTestScene - { - private BeatmapManager manager = null!; - private RulesetStore rulesets = null!; - private MusicController music = null!; - private WorkingBeatmap defaultBeatmap = null!; - private OsuConfigManager config = null!; - private TestSongSelect? songSelect; - - [BackgroundDependencyLoader] - private void load(GameHost host, AudioManager audio) - { - BeatmapStore beatmapStore; - - // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. - // At a point we have isolated interactive test runs enough, this can likely be removed. - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(Realm); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); - Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); - - Dependencies.Cache(music = new MusicController()); - - // required to get bindables attached - Add(music); - Add(beatmapStore); - - Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); - } - - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("reset defaults", () => - { - Ruleset.Value = new OsuRuleset().RulesetInfo; - - Beatmap.SetDefault(); - SelectedMods.SetDefault(); - - songSelect = null; - }); - - AddStep("delete all beatmaps", () => manager.Delete()); - } - - [Test] - public void TestSpeedChange() - { - createSongSelect(); - changeMods(); - - decreaseModSpeed(); - AddAssert("half time activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); - - decreaseModSpeed(); - AddAssert("half time speed changed to 0.9x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.9).Within(0.005)); - - increaseModSpeed(); - AddAssert("half time speed changed to 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); - - increaseModSpeed(); - AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); - - increaseModSpeed(); - AddAssert("double time activated at 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); - - increaseModSpeed(); - AddAssert("double time speed changed to 1.1x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.1).Within(0.005)); - - decreaseModSpeed(); - AddAssert("double time speed changed to 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); - - OsuModNightcore nc = new OsuModNightcore - { - SpeedChange = { Value = 1.05 } - }; - changeMods(nc); - - increaseModSpeed(); - AddAssert("nightcore speed changed to 1.1x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.1).Within(0.005)); - - decreaseModSpeed(); - AddAssert("nightcore speed changed to 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); - - decreaseModSpeed(); - AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); - - decreaseModSpeed(); - AddAssert("daycore activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); - - decreaseModSpeed(); - AddAssert("daycore activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.9).Within(0.005)); - - increaseModSpeed(); - AddAssert("daycore activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); - - OsuModDoubleTime dt = new OsuModDoubleTime - { - SpeedChange = { Value = 1.02 }, - AdjustPitch = { Value = true }, - }; - changeMods(dt); - - decreaseModSpeed(); - AddAssert("half time activated at 0.97x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.97).Within(0.005)); - AddAssert("adjust pitch preserved", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); - - OsuModHalfTime ht = new OsuModHalfTime - { - SpeedChange = { Value = 0.97 }, - AdjustPitch = { Value = true }, - }; - Mod[] modlist = { ht, new OsuModHardRock(), new OsuModHidden() }; - changeMods(modlist); - - increaseModSpeed(); - AddAssert("double time activated at 1.02x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.02).Within(0.005)); - AddAssert("double time activated at 1.02x", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); - AddAssert("HD still enabled", () => songSelect!.Mods.Value.OfType().SingleOrDefault(), () => Is.Not.Null); - AddAssert("HR still enabled", () => songSelect!.Mods.Value.OfType().SingleOrDefault(), () => Is.Not.Null); - - changeMods(new ModWindUp()); - increaseModSpeed(); - AddAssert("windup still active", () => songSelect!.Mods.Value.First() is ModWindUp); - - changeMods(new ModAdaptiveSpeed()); - increaseModSpeed(); - AddAssert("adaptive speed still active", () => songSelect!.Mods.Value.First() is ModAdaptiveSpeed); - - OsuModDoubleTime dtWithAdjustPitch = new OsuModDoubleTime - { - SpeedChange = { Value = 1.05 }, - AdjustPitch = { Value = true }, - }; - changeMods(dtWithAdjustPitch); - - decreaseModSpeed(); - AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); - - decreaseModSpeed(); - AddAssert("half time activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); - AddAssert("half time has adjust pitch active", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); - - AddStep("turn off adjust pitch", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value = false); - - increaseModSpeed(); - AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); - - increaseModSpeed(); - AddAssert("double time activated at 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); - AddAssert("double time has adjust pitch inactive", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.False); - - void increaseModSpeed() => AddStep("increase mod speed", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.Up); - InputManager.ReleaseKey(Key.ControlLeft); - }); - - void decreaseModSpeed() => AddStep("decrease mod speed", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.Down); - InputManager.ReleaseKey(Key.ControlLeft); - }); - } - - [Test] - public void TestPlaceholderBeatmapPresence() - { - createSongSelect(); - - AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); - - addRulesetImportStep(0); - AddUntilStep("wait for placeholder hidden", () => getPlaceholder()?.State.Value == Visibility.Hidden); - - AddStep("delete all beatmaps", () => manager.Delete()); - AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); - } - - [Test] - public void TestPlaceholderStarDifficulty() - { - addRulesetImportStep(0); - AddStep("change star filter", () => config.SetValue(OsuSetting.DisplayStarsMinimum, 10.0)); - - createSongSelect(); - - AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); - - AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); - - AddUntilStep("star filter reset", () => config.Get(OsuSetting.DisplayStarsMinimum) == 0.0); - AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Hidden); - } - - [Test] - public void TestPlaceholderConvertSetting() - { - addRulesetImportStep(0); - AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); - - createSongSelect(); - - changeRuleset(2); - - AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); - - AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); - - AddUntilStep("convert setting changed", () => config.Get(OsuSetting.ShowConvertedBeatmaps)); - AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Hidden); - } - - [Test] - public void TestSingleFilterOnEnter() - { - addRulesetImportStep(0); - addRulesetImportStep(0); - - createSongSelect(); - - AddAssert("filter count is 0", () => songSelect?.FilterCount, () => Is.EqualTo(0)); - } - - [Test] - public void TestChangeBeatmapBeforeEnter() - { - addRulesetImportStep(0); - - createSongSelect(); - - waitForInitialSelection(); - - WorkingBeatmap? selected = null; - - AddStep("store selected beatmap", () => selected = Beatmap.Value); - - AddStep("select next and enter", () => - { - InputManager.Key(Key.Down); - InputManager.Key(Key.Enter); - }); - - waitForDismissed(); - AddAssert("ensure selection changed", () => selected != Beatmap.Value); - } - - [Test] - public void TestChangeBeatmapAfterEnter() - { - addRulesetImportStep(0); - - createSongSelect(); - - waitForInitialSelection(); - - WorkingBeatmap? selected = null; - - AddStep("store selected beatmap", () => selected = Beatmap.Value); - - AddStep("select next and enter", () => - { - InputManager.Key(Key.Enter); - InputManager.Key(Key.Down); - }); - - waitForDismissed(); - AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); - } - - [Test] - public void TestChangeBeatmapViaMouseBeforeEnter() - { - addRulesetImportStep(0); - - createSongSelect(); - - AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - - WorkingBeatmap? selected = null; - - AddStep("store selected beatmap", () => selected = Beatmap.Value); - - AddUntilStep("wait for beatmaps to load", () => songSelect!.Carousel.ChildrenOfType().Any()); - - AddStep("select next and enter", () => - { - InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item!).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); - - InputManager.Click(MouseButton.Left); - - InputManager.Key(Key.Enter); - }); - - waitForDismissed(); - AddAssert("ensure selection changed", () => selected != Beatmap.Value); - } - - [Test] - public void TestChangeBeatmapViaMouseAfterEnter() - { - addRulesetImportStep(0); - - createSongSelect(); - - waitForInitialSelection(); - - WorkingBeatmap? selected = null; - - AddStep("store selected beatmap", () => selected = Beatmap.Value); - - AddStep("select next and enter", () => - { - InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item!).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); - - InputManager.PressButton(MouseButton.Left); - - InputManager.Key(Key.Enter); - - InputManager.ReleaseButton(MouseButton.Left); - }); - - waitForDismissed(); - AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); - } - - [Test] - public void TestNoFilterOnSimpleResume() - { - addRulesetImportStep(0); - addRulesetImportStep(0); - - createSongSelect(); - - AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - waitForDismissed(); - - AddStep("return", () => songSelect!.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); - AddAssert("filter count is 0", () => songSelect!.FilterCount, () => Is.EqualTo(0)); - } - - [Test] - public void TestFilterOnResumeAfterChange() - { - addRulesetImportStep(0); - addRulesetImportStep(0); - - AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); - - createSongSelect(); - - AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - waitForDismissed(); - - AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - - AddStep("return", () => songSelect!.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); - AddAssert("filter count is 1", () => songSelect!.FilterCount, () => Is.EqualTo(1)); - } - - [Test] - public void TestCarouselSelectionUpdatesOnResume() - { - addRulesetImportStep(0); - - createSongSelect(); - - AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - waitForDismissed(); - - AddStep("update beatmap", () => - { - var selectedBeatmap = Beatmap.Value.BeatmapInfo; - var anotherBeatmap = Beatmap.Value.BeatmapSetInfo.Beatmaps.Except(selectedBeatmap.Yield()).First(); - Beatmap.Value = manager.GetWorkingBeatmap(anotherBeatmap); - }); - - AddStep("return", () => songSelect!.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); - AddAssert("carousel updated", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(Beatmap.Value.BeatmapInfo) == true); - } - - [Test] - public void TestAudioResuming() - { - createSongSelect(); - - // We need to use one real beatmap to trigger the "same-track-transfer" logic that we're looking to test here. - // See `SongSelect.ensurePlayingSelected` and `WorkingBeatmap.TryTransferTrack`. - AddStep("import test beatmap", () => manager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).WaitSafely()); - addRulesetImportStep(0); - - checkMusicPlaying(true); - AddStep("select first", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.First())); - checkMusicPlaying(true); - - AddStep("manual pause", () => music.TogglePause()); - checkMusicPlaying(false); - - // Track should not have changed, so music should still not be playing. - AddStep("select next difficulty", () => songSelect!.Carousel.SelectNext(skipDifficulties: false)); - checkMusicPlaying(false); - - AddStep("select next set", () => songSelect!.Carousel.SelectNext()); - checkMusicPlaying(true); - } - - [TestCase(false)] - [TestCase(true)] - public void TestAudioRemainsCorrectOnRulesetChange(bool rulesetsInSameBeatmap) - { - createSongSelect(); - - // start with non-osu! to avoid convert confusion - changeRuleset(1); - - if (rulesetsInSameBeatmap) - { - AddStep("import multi-ruleset map", () => - { - var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); - manager.Import(TestResources.CreateTestBeatmapSetInfo(rulesets: usableRulesets)); - }); - } - else - { - addRulesetImportStep(1); - addRulesetImportStep(0); - } - - checkMusicPlaying(true); - - AddStep("manual pause", () => music.TogglePause()); - checkMusicPlaying(false); - - changeRuleset(0); - checkMusicPlaying(!rulesetsInSameBeatmap); - } - - [Test] - public void TestDummy() - { - createSongSelect(); - AddUntilStep("dummy selected", () => songSelect!.CurrentBeatmap == defaultBeatmap); - - AddUntilStep("dummy shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap == defaultBeatmap); - - addManyTestMaps(); - - AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); - } - - [Test] - public void TestSorting() - { - createSongSelect(); - addManyTestMaps(); - - AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); - - AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); - AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); - AddStep(@"Sort by Author", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Author)); - AddStep(@"Sort by DateAdded", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); - AddStep(@"Sort by BPM", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.BPM)); - AddStep(@"Sort by Length", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Length)); - AddStep(@"Sort by Difficulty", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); - AddStep(@"Sort by Source", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Source)); - } - - [Test] - public void TestImportUnderDifferentRuleset() - { - createSongSelect(); - addRulesetImportStep(2); - AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - } - - [Test] - public void TestImportUnderCurrentRuleset() - { - createSongSelect(); - changeRuleset(2); - addRulesetImportStep(2); - addRulesetImportStep(1); - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); - - changeRuleset(1); - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 1); - - changeRuleset(0); - AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - } - - [Test] - [Ignore("temporary while peppy investigates. probably realm batching related.")] - public void TestSelectionRetainedOnBeatmapUpdate() - { - createSongSelect(); - changeRuleset(0); - - Live? original = null; - int originalOnlineSetID = 0; - - AddStep(@"Sort by artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); - - AddStep("import original", () => - { - original = manager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely(); - - Debug.Assert(original != null); - - originalOnlineSetID = original.Value.OnlineID; - }); - - // This will move the beatmap set to a different location in the carousel. - AddStep("Update original with bogus info", () => - { - Debug.Assert(original != null); - - original.PerformWrite(set => - { - foreach (var beatmap in set.Beatmaps) - { - beatmap.Metadata.Artist = "ZZZZZ"; - beatmap.OnlineID = 12804; - } - }); - }); - - AddRepeatStep("import other beatmaps", () => - { - var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(); - - foreach (var beatmap in testBeatmapSetInfo.Beatmaps) - beatmap.Metadata.Artist = ((char)RNG.Next('A', 'Z')).ToString(); - - manager.Import(testBeatmapSetInfo); - }, 10); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID, () => Is.EqualTo(originalOnlineSetID)); - - Task?> updateTask = null!; - - AddStep("update beatmap", () => - { - Debug.Assert(original != null); - - updateTask = manager.ImportAsUpdate(new ProgressNotification(), new ImportTask(TestResources.GetQuickTestBeatmapForImport()), original.Value); - }); - AddUntilStep("wait for update completion", () => updateTask.IsCompleted); - - AddUntilStep("retained selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID, () => Is.EqualTo(originalOnlineSetID)); - } - - [Test] - public void TestPresentNewRulesetNewBeatmap() - { - createSongSelect(); - changeRuleset(2); - - addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); - - addRulesetImportStep(0); - addRulesetImportStep(0); - addRulesetImportStep(0); - - BeatmapInfo? target = null; - - AddStep("select beatmap/ruleset externally", () => - { - target = manager.GetAllUsableBeatmapSets() - .Last(b => b.Beatmaps.Any(bi => bi.Ruleset.OnlineID == 0)).Beatmaps.Last(); - - Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 0); - Beatmap.Value = manager.GetWorkingBeatmap(target); - }); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); - - // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); - } - - [Test] - public void TestPresentNewBeatmapNewRuleset() - { - createSongSelect(); - changeRuleset(2); - - addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); - - addRulesetImportStep(0); - addRulesetImportStep(0); - addRulesetImportStep(0); - - BeatmapInfo? target = null; - - AddStep("select beatmap/ruleset externally", () => - { - target = manager.GetAllUsableBeatmapSets() - .Last(b => b.Beatmaps.Any(bi => bi.Ruleset.OnlineID == 0)).Beatmaps.Last(); - - Beatmap.Value = manager.GetWorkingBeatmap(target); - Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 0); - }); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); - - AddUntilStep("has correct ruleset", () => Ruleset.Value.OnlineID == 0); - - // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); - } - - [Test] - public void TestModsRetainedBetweenSongSelect() - { - AddAssert("empty mods", () => !SelectedMods.Value.Any()); - - createSongSelect(); - - addRulesetImportStep(0); - - changeMods(new OsuModHardRock()); - - createSongSelect(); - - AddAssert("mods retained", () => SelectedMods.Value.Any()); - } - - [Test] - public void TestStartAfterUnMatchingFilterDoesNotStart() - { - createSongSelect(); - addManyTestMaps(); - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - - bool startRequested = false; - - AddStep("set filter and finalize", () => - { - songSelect!.StartRequested = () => startRequested = true; - - songSelect!.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); - songSelect!.FinaliseSelection(); - - songSelect!.StartRequested = null; - }); - - AddAssert("start not requested", () => !startRequested); - } - - [Test] - public void TestSearchTextWithRulesetCriteria() - { - createSongSelect(); - - addRulesetImportStep(0); - - AddStep("disallow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - - AddStep("set filter to match all", () => songSelect!.FilterControl.CurrentTextSearch.Value = "Some"); - - changeRuleset(1); - - AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - } - - [TestCase(false)] - [TestCase(true)] - public void TestExternalBeatmapChangeWhileFiltered(bool differentRuleset) - { - createSongSelect(); - // ensure there is at least 1 difficulty for each of the rulesets - // (catch is excluded inside of addManyTestMaps). - addManyTestMaps(3); - - changeRuleset(0); - - // used for filter check below - AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); - - AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - - AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - - BeatmapInfo? target = null; - - int targetRuleset = differentRuleset ? 1 : 0; - - AddStep("select beatmap externally", () => - { - target = manager.GetAllUsableBeatmapSets() - .First(b => b.Beatmaps.Any(bi => bi.Ruleset.OnlineID == targetRuleset)) - .Beatmaps - .First(bi => bi.Ruleset.OnlineID == targetRuleset); - - Beatmap.Value = manager.GetWorkingBeatmap(target); - }); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - - AddAssert("selected only shows expected ruleset (plus converts)", () => - { - var selectedPanel = songSelect!.Carousel.ChildrenOfType().First(s => s.Item!.State.Value == CarouselItemState.Selected); - - // special case for converts checked here. - return selectedPanel.ChildrenOfType().All(i => - i.IsFiltered || i.Item.BeatmapInfo.Ruleset.OnlineID == targetRuleset || i.Item.BeatmapInfo.Ruleset.OnlineID == 0); - }); - - AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); - AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - - AddStep("reset filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = string.Empty); - - AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.MatchesOnlineID(target) == true); - AddAssert("carousel still correct", () => songSelect!.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); - } - - [Test] - public void TestExternalBeatmapChangeWhileFilteredThenRefilter() - { - createSongSelect(); - // ensure there is at least 1 difficulty for each of the rulesets - // (catch is excluded inside of addManyTestMaps). - addManyTestMaps(3); - - changeRuleset(0); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); - - AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - - AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - - BeatmapInfo? target = null; - - AddStep("select beatmap externally", () => - { - target = manager - .GetAllUsableBeatmapSets() - .First(b => b.Beatmaps.Any(bi => bi.Ruleset.OnlineID == 1)) - .Beatmaps.First(); - - Beatmap.Value = manager.GetWorkingBeatmap(target); - }); - - AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - - AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); - AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nononoo"); - - AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); - AddAssert("carousel lost selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - } - - [Test] - public void TestAutoplayShortcut() - { - addRulesetImportStep(0); - - createSongSelect(); - - AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - - AddStep("press ctrl+enter", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.Enter); - InputManager.ReleaseKey(Key.ControlLeft); - }); - - AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - - AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - - AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - - AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); - } - - [Test] - public void TestAutoplayShortcutKeepsAutoplayIfSelectedAlready() - { - addRulesetImportStep(0); - - createSongSelect(); - - AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - - changeMods(new OsuModAutoplay()); - - AddStep("press ctrl+enter", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.Enter); - InputManager.ReleaseKey(Key.ControlLeft); - }); - - AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - - AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - - AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - - AddAssert("autoplay still selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - } - - [Test] - public void TestAutoplayShortcutReturnsInitialModsOnExit() - { - addRulesetImportStep(0); - - createSongSelect(); - - AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - - changeMods(new OsuModRelax()); - - AddStep("press ctrl+enter", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.Enter); - InputManager.ReleaseKey(Key.ControlLeft); - }); - - AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - - AddAssert("only autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - - AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - - AddAssert("relax returned", () => songSelect!.Mods.Value.Single() is ModRelax); - } - - [Test] - public void TestHideSetSelectsCorrectBeatmap() - { - Guid? previousID = null; - createSongSelect(); - addRulesetImportStep(0); - AddStep("Move to last difficulty", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.Last())); - AddStep("Store current ID", () => previousID = songSelect!.Carousel.SelectedBeatmapInfo!.ID); - AddStep("Hide first beatmap", () => manager.Hide(songSelect!.Carousel.SelectedBeatmapSet!.Beatmaps.First())); - AddAssert("Selected beatmap has not changed", () => songSelect!.Carousel.SelectedBeatmapInfo?.ID == previousID); - } - - [Test] - public void TestDifficultyIconSelecting() - { - addRulesetImportStep(0); - createSongSelect(); - - AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - - DrawableCarouselBeatmapSet set = null!; - AddStep("Find the DrawableCarouselBeatmapSet", () => - { - set = songSelect!.Carousel.ChildrenOfType().First(); - }); - - FilterableDifficultyIcon difficultyIcon = null!; - - AddUntilStep("Find an icon", () => - { - var foundIcon = set.ChildrenOfType() - .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); - - if (foundIcon == null) - return false; - - difficultyIcon = foundIcon; - return true; - }); - - AddStep("Click on a difficulty", () => - { - InputManager.MoveMouseTo(difficultyIcon); - - InputManager.Click(MouseButton.Left); - }); - - AddAssert("Selected beatmap correct", () => getCurrentBeatmapIndex() == getDifficultyIconIndex(set, difficultyIcon)); - - double? maxBPM = null; - AddStep("Filter some difficulties", () => songSelect!.Carousel.Filter(new FilterCriteria - { - BPM = new FilterCriteria.OptionalRange - { - Min = maxBPM = songSelect!.Carousel.SelectedBeatmapSet!.MaxBPM, - IsLowerInclusive = true - } - })); - - BeatmapInfo? filteredBeatmap = null; - FilterableDifficultyIcon? filteredIcon = null; - - AddStep("Get filtered icon", () => - { - var selectedSet = songSelect!.Carousel.SelectedBeatmapSet; - - Debug.Assert(selectedSet != null); - - filteredBeatmap = selectedSet.Beatmaps.First(b => b.BPM < maxBPM); - int filteredBeatmapIndex = getBeatmapIndex(selectedSet, filteredBeatmap); - filteredIcon = set.ChildrenOfType().ElementAt(filteredBeatmapIndex); - }); - - AddStep("Click on a filtered difficulty", () => - { - Debug.Assert(filteredIcon != null); - - InputManager.MoveMouseTo(filteredIcon); - - InputManager.Click(MouseButton.Left); - }); - - AddAssert("Selected beatmap correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(filteredBeatmap) == true); - } - - [Test] - public void TestChangingRulesetOnMultiRulesetBeatmap() - { - int changeCount = 0; - - AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); - AddStep("bind beatmap changed", () => - { - Beatmap.ValueChanged += onChange; - changeCount = 0; - }); - - changeRuleset(0); - - createSongSelect(); - - AddStep("import multi-ruleset map", () => - { - var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); - manager.Import(TestResources.CreateTestBeatmapSetInfo(3, usableRulesets)); - }); - - int previousSetID = 0; - - AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - - AddStep("record set ID", () => previousSetID = ((IBeatmapSetInfo)Beatmap.Value.BeatmapSetInfo).OnlineID); - AddAssert("selection changed once", () => changeCount == 1); - - AddAssert("Check ruleset is osu!", () => Ruleset.Value.OnlineID == 0); - - changeRuleset(3); - - AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.OnlineID == 3); - - AddUntilStep("selection changed", () => changeCount > 1); - - AddAssert("Selected beatmap still same set", () => Beatmap.Value.BeatmapSetInfo.OnlineID == previousSetID); - AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID == 3); - - AddAssert("selection changed only fired twice", () => changeCount == 2); - - AddStep("unbind beatmap changed", () => Beatmap.ValueChanged -= onChange); - AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - - // ReSharper disable once AccessToModifiedClosure - void onChange(ValueChangedEvent valueChangedEvent) => changeCount++; - } - - [Test] - public void TestDifficultyIconSelectingForDifferentRuleset() - { - changeRuleset(0); - - createSongSelect(); - - AddStep("import multi-ruleset map", () => - { - var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); - manager.Import(TestResources.CreateTestBeatmapSetInfo(3, usableRulesets)); - }); - - DrawableCarouselBeatmapSet? set = null; - AddUntilStep("Find the DrawableCarouselBeatmapSet", () => - { - set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); - return set != null; - }); - - FilterableDifficultyIcon? difficultyIcon = null; - AddUntilStep("Find an icon for different ruleset", () => - { - difficultyIcon = set.ChildrenOfType() - .FirstOrDefault(icon => icon.Item.BeatmapInfo.Ruleset.OnlineID == 3); - return difficultyIcon != null; - }); - - AddAssert("Check ruleset is osu!", () => Ruleset.Value.OnlineID == 0); - - int previousSetID = 0; - - AddStep("record set ID", () => previousSetID = ((IBeatmapSetInfo)Beatmap.Value.BeatmapSetInfo).OnlineID); - - AddStep("Click on a difficulty", () => - { - Debug.Assert(difficultyIcon != null); - - InputManager.MoveMouseTo(difficultyIcon); - - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.OnlineID == 3); - - AddAssert("Selected beatmap still same set", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == previousSetID); - AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID == 3); - } - - [Test] - public void TestGroupedDifficultyIconSelecting() - { - changeRuleset(0); - - createSongSelect(); - - BeatmapSetInfo? imported = null; - - AddStep("import huge difficulty count map", () => - { - var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); - imported = manager.Import(TestResources.CreateTestBeatmapSetInfo(50, usableRulesets))?.Value; - }); - - AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported?.Beatmaps.First())); - - DrawableCarouselBeatmapSet? set = null; - AddUntilStep("Find the DrawableCarouselBeatmapSet", () => - { - set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); - return set != null; - }); - - GroupedDifficultyIcon groupIcon = null!; - - AddUntilStep("Find group icon for different ruleset", () => - { - var foundIcon = set.ChildrenOfType() - .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3); - - if (foundIcon == null) - return false; - - groupIcon = foundIcon; - return true; - }); - - AddAssert("Check ruleset is osu!", () => Ruleset.Value.OnlineID == 0); - - AddStep("Click on group", () => - { - InputManager.MoveMouseTo(groupIcon); - - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.OnlineID == 3); - - AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(groupIcon.Items.First().BeatmapInfo)); - } - - [Test] - public void TestChangeRulesetWhilePresentingScore() - { - BeatmapInfo getPresentBeatmap() => manager.GetAllUsableBeatmapSets().Where(s => !s.DeletePending).SelectMany(s => s.Beatmaps).First(b => b.Ruleset.OnlineID == 0); - BeatmapInfo getSwitchBeatmap() => manager.GetAllUsableBeatmapSets().Where(s => !s.DeletePending).SelectMany(s => s.Beatmaps).First(b => b.Ruleset.OnlineID == 1); - - changeRuleset(0); - - createSongSelect(); - - addRulesetImportStep(0); - addRulesetImportStep(1); - - AddStep("present score", () => - { - // this ruleset change should be overridden by the present. - Ruleset.Value = getSwitchBeatmap().Ruleset; - - songSelect!.PresentScore(new ScoreInfo - { - User = new APIUser { Username = "woo" }, - BeatmapInfo = getPresentBeatmap(), - Ruleset = getPresentBeatmap().Ruleset - }); - }); - - waitForDismissed(); - - AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); - AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); - } - - [Test] - public void TestChangeBeatmapWhilePresentingScore() - { - BeatmapInfo getPresentBeatmap() => manager.GetAllUsableBeatmapSets().Where(s => !s.DeletePending).SelectMany(s => s.Beatmaps).First(b => b.Ruleset.OnlineID == 0); - BeatmapInfo getSwitchBeatmap() => manager.GetAllUsableBeatmapSets().Where(s => !s.DeletePending).SelectMany(s => s.Beatmaps).First(b => b.Ruleset.OnlineID == 1); - - changeRuleset(0); - - addRulesetImportStep(0); - addRulesetImportStep(1); - - createSongSelect(); - - AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - - AddStep("present score", () => - { - // this beatmap change should be overridden by the present. - Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); - - songSelect!.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); - }); - - waitForDismissed(); - - AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); - AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); - } - - [Test] - public void TestModOverlayToggling() - { - changeRuleset(0); - createSongSelect(); - - AddStep("toggle mod overlay on", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay shown", () => songSelect!.ModSelect.State.Value == Visibility.Visible); - - AddStep("toggle mod overlay off", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay hidden", () => songSelect!.ModSelect.State.Value == Visibility.Hidden); - } - - [Test] - public void TestBeatmapOptionsDisabled() - { - createSongSelect(); - - addRulesetImportStep(0); - - AddAssert("options enabled", () => songSelect.ChildrenOfType().Single().Enabled.Value); - AddStep("delete all beatmaps", () => manager.Delete()); - AddUntilStep("wait for no beatmap", () => Beatmap.IsDefault); - AddAssert("options disabled", () => !songSelect.ChildrenOfType().Single().Enabled.Value); - } - - [Test] - public void TestTextBoxBeatmapDifficultyCount() - { - createSongSelect(); - - AddAssert("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); - - addRulesetImportStep(0); - - AddAssert("3 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "3 matches"); - AddStep("delete all beatmaps", () => manager.Delete()); - AddUntilStep("wait for no beatmap", () => Beatmap.IsDefault); - AddAssert("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); - } - - [Test] - public void TestHardDeleteHandledCorrectly() - { - createSongSelect(); - - addRulesetImportStep(0); - AddAssert("3 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "3 matches"); - - AddStep("hard delete beatmap", () => Realm.Write(r => r.RemoveRange(r.All().Where(s => !s.Protected)))); - - AddUntilStep("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); - } - - [Test] - public void TestDeleteHotkey() - { - createSongSelect(); - - addRulesetImportStep(0); - AddAssert("3 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "3 matches"); - - AddStep("press shift-delete", () => - { - InputManager.PressKey(Key.ShiftLeft); - InputManager.Key(Key.Delete); - InputManager.ReleaseKey(Key.ShiftLeft); - }); - AddUntilStep("delete dialog shown", () => DialogOverlay.CurrentDialog, Is.InstanceOf); - AddStep("confirm deletion", () => DialogOverlay.CurrentDialog!.PerformAction()); - AddAssert("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); - } - - [Test] - public void TestCutInFilterTextBox() - { - createSongSelect(); - - AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); - AddStep("select all", () => InputManager.Keys(PlatformAction.SelectAll)); - AddStep("press ctrl/cmd-x", () => InputManager.Keys(PlatformAction.Cut)); - - AddAssert("filter text cleared", () => songSelect!.FilterControl.ChildrenOfType().First().Text, () => Is.Empty); - } - - [Test] - public void TestNonFilterableModChange() - { - addRulesetImportStep(0); - - createSongSelect(); - - // Mod that is guaranteed to never re-filter. - AddStep("add non-filterable mod", () => SelectedMods.Value = new Mod[] { new OsuModCinema() }); - AddAssert("filter count is 0", () => songSelect!.FilterCount, () => Is.EqualTo(0)); - - // Removing the mod should still not re-filter. - AddStep("remove non-filterable mod", () => SelectedMods.Value = Array.Empty()); - AddAssert("filter count is 0", () => songSelect!.FilterCount, () => Is.EqualTo(0)); - } - - [Test] - public void TestFilterableModChange() - { - addRulesetImportStep(3); - - createSongSelect(); - - // Change to mania ruleset. - AddStep("filter to mania ruleset", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 3)); - AddAssert("filter count is 2", () => songSelect!.FilterCount, () => Is.EqualTo(1)); - - // Apply a mod, but this should NOT re-filter because there's no search text. - AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); - AddAssert("filter count is 1", () => songSelect!.FilterCount, () => Is.EqualTo(1)); - - // Set search text. Should re-filter. - AddStep("set search text to match mods", () => songSelect!.FilterControl.CurrentTextSearch.Value = "keys=3"); - AddAssert("filter count is 2", () => songSelect!.FilterCount, () => Is.EqualTo(2)); - - // Change filterable mod. Should re-filter. - AddStep("change new filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey5() }); - AddAssert("filter count is 3", () => songSelect!.FilterCount, () => Is.EqualTo(3)); - - // Add non-filterable mod. Should NOT re-filter. - AddStep("apply non-filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail(), new ManiaModKey5() }); - AddAssert("filter count is 3", () => songSelect!.FilterCount, () => Is.EqualTo(3)); - - // Remove filterable mod. Should re-filter. - AddStep("remove filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail() }); - AddAssert("filter count is 4", () => songSelect!.FilterCount, () => Is.EqualTo(4)); - - // Remove non-filterable mod. Should NOT re-filter. - AddStep("remove filterable mod", () => SelectedMods.Value = Array.Empty()); - AddAssert("filter count is 4", () => songSelect!.FilterCount, () => Is.EqualTo(4)); - - // Add filterable mod. Should re-filter. - AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); - AddAssert("filter count is 5", () => songSelect!.FilterCount, () => Is.EqualTo(5)); - } - - private void waitForInitialSelection() - { - AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - AddUntilStep("wait for difficulty panels visible", () => songSelect!.Carousel.ChildrenOfType().Any()); - } - - private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.IndexOf(info); - - private NoResultsPlaceholder? getPlaceholder() => songSelect!.ChildrenOfType().FirstOrDefault(); - - private int getCurrentBeatmapIndex() - { - Debug.Assert(songSelect!.Carousel.SelectedBeatmapSet != null); - Debug.Assert(songSelect!.Carousel.SelectedBeatmapInfo != null); - - return getBeatmapIndex(songSelect!.Carousel.SelectedBeatmapSet, songSelect!.Carousel.SelectedBeatmapInfo); - } - - private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) - { - return set.ChildrenOfType().ToList().FindIndex(i => i == icon); - } - - private void addRulesetImportStep(int id) - { - Live? imported = null; - AddStep($"import test map for ruleset {id}", () => imported = importForRuleset(id)); - // This is specifically for cases where the add is happening post song select load. - // For cases where song select is null, the assertions are provided by the load checks. - AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect!.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); - } - - private Live? importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); - - private void checkMusicPlaying(bool playing) => - AddUntilStep($"music {(playing ? "" : "not ")}playing", () => music.IsPlaying == playing); - - private void changeMods(params Mod[] mods) => AddStep($"change mods to {string.Join(", ", mods.Select(m => m.Acronym))}", () => SelectedMods.Value = mods); - - private void changeRuleset(int id) => AddStep($"change ruleset to {id}", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == id)); - - private void createSongSelect() - { - AddStep("create song select", () => LoadScreen(songSelect = new TestSongSelect())); - AddUntilStep("wait for present", () => songSelect!.IsCurrentScreen()); - AddUntilStep("wait for carousel loaded", () => songSelect!.Carousel.IsAlive); - } - - /// - /// Imports test beatmap sets to show in the carousel. - /// - /// - /// The exact count of difficulties to create for each beatmap set. - /// A value causes the count of difficulties to be selected randomly. - /// - private void addManyTestMaps(int? difficultyCountPerSet = null) - { - AddStep("import test maps", () => - { - var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); - - for (int i = 0; i < 10; i++) - manager.Import(TestResources.CreateTestBeatmapSetInfo(difficultyCountPerSet, usableRulesets)); - }); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (rulesets.IsNotNull()) - rulesets.Dispose(); - } - - private void waitForDismissed() => AddUntilStep("wait for not current", () => !songSelect.AsNonNull().IsCurrentScreen()); - - private partial class TestSongSelect : PlaySongSelect - { - public Action? StartRequested; - - public new FilterControl FilterControl => base.FilterControl; - - public WorkingBeatmap CurrentBeatmap => Beatmap.Value; - public IWorkingBeatmap CurrentBeatmapDetailsBeatmap => BeatmapDetails.Beatmap; - public new BeatmapCarousel Carousel => base.Carousel; - public new ModSelectOverlay ModSelect => base.ModSelect; - - public new void PresentScore(ScoreInfo score) => base.PresentScore(score); - - public int FilterCount; - - protected override bool OnStart() - { - StartRequested?.Invoke(); - return base.OnStart(); - } - - [BackgroundDependencyLoader] - private void load() - { - FilterControl.FilterChanged += _ => FilterCount++; - } - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs index 992651d73c..8fcb3d7acc 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs @@ -28,7 +28,6 @@ using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; -using osu.Game.Tests.Visual.SongSelect; using osu.Game.Users; using osuTK.Input; @@ -118,8 +117,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { setScope(BeatmapLeaderboardScope.Global); - AddStep(@"New Scores", () => leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()))); - AddStep(@"New Scores with teams", () => leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()).Select(s => + AddStep(@"New Scores", () => leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()))); + AddStep(@"New Scores with teams", () => leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()).Select(s => { s.User.Team = new APITeam(); return s; @@ -150,7 +149,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestUseTheseModsDoesNotCopySystemMods() { - AddStep(@"set scores", () => leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()), new ScoreInfo + AddStep(@"set scores", () => leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()), new ScoreInfo { OnlineID = 1337, Position = 999, @@ -297,7 +296,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private void showPersonalBestWithNullPosition() { - leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()), new ScoreInfo + leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()), new ScoreInfo { OnlineID = 1337, Rank = ScoreRank.XH, @@ -318,7 +317,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private void showPersonalBest() { - leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()), new ScoreInfo + leaderboard.SetScores(GenerateSampleScores(new BeatmapInfo()), new ScoreInfo { OnlineID = 1337, Position = 999, @@ -347,7 +346,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { AddStep(@"Import new scores", () => { - foreach (var score in TestSceneBeatmapLeaderboard.GenerateSampleScores(beatmapInfo())) + foreach (var score in GenerateSampleScores(beatmapInfo())) scoreManager.Import(score); }); } @@ -368,5 +367,216 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public new void SetState(LeaderboardState state) => base.SetState(state); public new void SetScores(IEnumerable scores, ScoreInfo? userScore = null, int? totalCount = null) => base.SetScores(scores, userScore, totalCount); } + + public static ScoreInfo[] GenerateSampleScores(BeatmapInfo beatmapInfo) + { + return new[] + { + new ScoreInfo + { + Rank = ScoreRank.XH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now, + Mods = new Mod[] + { + new OsuModHidden(), + new OsuModFlashlight + { + FollowDelay = { Value = 200 }, + SizeMultiplier = { Value = 5 }, + }, + new OsuModDifficultyAdjust + { + CircleSize = { Value = 11 }, + ApproachRate = { Value = 10 }, + OverallDifficulty = { Value = 10 }, + DrainRate = { Value = 10 }, + ExtendedLimits = { Value = true } + } + }, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + User = new APIUser + { + Id = 6602580, + Username = @"waaiiru", + CountryCode = CountryCode.ES, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.X, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddSeconds(-30), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + User = new APIUser + { + Id = 4608074, + Username = @"Skycries", + CountryCode = CountryCode.BR, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.SH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddSeconds(-70), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 1014222, + Username = @"eLy", + CountryCode = CountryCode.JP, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.S, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddMinutes(-40), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 1541390, + Username = @"Toukai", + CountryCode = CountryCode.CA, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.A, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddHours(-2), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 2243452, + Username = @"Satoruu", + CountryCode = CountryCode.VE, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.B, + Accuracy = 0.9826, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddHours(-25), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 2705430, + Username = @"Mooha", + CountryCode = CountryCode.FR, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.C, + Accuracy = 0.9654, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddHours(-50), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 7151382, + Username = @"Mayuri Hana", + CountryCode = CountryCode.TH, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.D, + Accuracy = 0.6025, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddHours(-72), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 2051389, + Username = @"FunOrange", + CountryCode = CountryCode.CA, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.D, + Accuracy = 0.5140, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddMonths(-10), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 6169483, + Username = @"-Hebel-", + CountryCode = CountryCode.MX, + }, + }, + new ScoreInfo + { + Rank = ScoreRank.D, + Accuracy = 0.4222, + MaxCombo = 244, + TotalScore = 1707827, + Date = DateTime.Now.AddYears(-2), + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, + Ruleset = new OsuRuleset().RulesetInfo, + + User = new APIUser + { + Id = 6702666, + Username = @"prhtnsm", + CountryCode = CountryCode.DE, + }, + }, + }; + } } } diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 66e8aaf008..2f046b3754 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -307,7 +307,7 @@ namespace osu.Game.Graphics.Carousel /// /// Retrieve a list of all s currently displayed. /// - protected IReadOnlyCollection? GetCarouselItems() => carouselItems; + public IReadOnlyCollection? GetCarouselItems() => carouselItems; private List? carouselItems; @@ -1028,7 +1028,7 @@ namespace osu.Game.Graphics.Carousel /// Implementation of scroll container which handles very large vertical lists by internally using double precision /// for pre-display Y values. /// - protected partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler + public partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler { public readonly Container Panels; diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs index e8dc58ff1b..be507e7b36 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs @@ -46,6 +46,8 @@ namespace osu.Game.Screens.SelectV2 { public const int HEIGHT = 50; + public readonly ScoreInfo Score; + public Bindable> SelectedMods = new Bindable>(); /// @@ -115,8 +117,6 @@ namespace osu.Game.Screens.SelectV2 private Container rankLabelStandalone = null!; private Container rankLabelOverlay = null!; - private readonly ScoreInfo score; - private readonly bool sheared; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) @@ -130,7 +130,8 @@ namespace osu.Game.Screens.SelectV2 public BeatmapLeaderboardScore(ScoreInfo score, bool sheared = true) { - this.score = score; + Score = score; + this.sheared = sheared; Shear = sheared ? OsuGame.SHEAR : Vector2.Zero; @@ -198,7 +199,7 @@ namespace osu.Game.Screens.SelectV2 new UserCoverBackground { RelativeSizeAxes = Axes.Both, - User = score.User, + User = Score.User, Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -224,7 +225,7 @@ namespace osu.Game.Screens.SelectV2 Masking = true, Children = new Drawable[] { - new DelayedLoadWrapper(innerAvatar = new ClickableAvatar(score.User) + new DelayedLoadWrapper(innerAvatar = new ClickableAvatar(Score.User) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -276,19 +277,19 @@ namespace osu.Game.Screens.SelectV2 Masking = true, Children = new Drawable[] { - new UpdateableFlag(score.User.CountryCode) + new UpdateableFlag(Score.User.CountryCode) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Size = new Vector2(20, 14), }, - new UpdateableTeamFlag(score.User.Team) + new UpdateableTeamFlag(Score.User.Team) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Size = new Vector2(30, 15), }, - new DateLabel(score.Date) + new DateLabel(Score.Date) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -301,7 +302,7 @@ namespace osu.Game.Screens.SelectV2 { RelativeSizeAxes = Axes.X, Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Text = score.User.Username, + Text = Score.User.Username, Font = OsuFont.Style.Heading2, } } @@ -323,9 +324,9 @@ namespace osu.Game.Screens.SelectV2 Direction = FillDirection.Horizontal, Children = new Drawable[] { - new ScoreComponentLabel(BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), $"{score.MaxCombo.ToString()}x", - score.MaxCombo == score.GetMaximumAchievableCombo(), 60), - new ScoreComponentLabel(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), score.DisplayAccuracy, score.Accuracy == 1, + new ScoreComponentLabel(BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), $"{Score.MaxCombo.ToString()}x", + Score.MaxCombo == Score.GetMaximumAchievableCombo(), 60), + new ScoreComponentLabel(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), Score.DisplayAccuracy, Score.Accuracy == 1, 55), }, Alpha = 0, @@ -357,7 +358,7 @@ namespace osu.Game.Screens.SelectV2 Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank)), + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(Score.Rank)), }, }, new Box @@ -366,7 +367,7 @@ namespace osu.Game.Screens.SelectV2 Width = grade_width, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Colour = OsuColour.ForRank(score.Rank), + Colour = OsuColour.ForRank(Score.Rank), }, new TrianglesV2 { @@ -376,7 +377,7 @@ namespace osu.Game.Screens.SelectV2 Origin = Anchor.TopRight, SpawnRatio = 2, Velocity = 0.7f, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Darken(0.2f)), + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(Score.Rank).Darken(0.2f)), }, new Container { @@ -390,9 +391,9 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.Centre, Origin = Anchor.Centre, Spacing = new Vector2(-2), - Colour = DrawableRank.GetRankNameColour(score.Rank), + Colour = DrawableRank.GetRankNameColour(Score.Rank), Font = OsuFont.Numeric.With(size: 14), - Text = DrawableRank.GetRankName(score.Rank), + Text = DrawableRank.GetRankName(Score.Rank), ShadowColour = Color4.Black.Opacity(0.3f), ShadowOffset = new Vector2(0, 0.08f), Shadow = true, @@ -420,7 +421,7 @@ namespace osu.Game.Screens.SelectV2 new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Opacity(0.5f)), + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(Score.Rank).Opacity(0.5f)), }, new FillFlowContainer { @@ -437,7 +438,7 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.TopRight, Origin = Anchor.TopRight, UseFullGlyphHeight = false, - Current = scoreManager.GetBindableTotalScoreString(score), + Current = scoreManager.GetBindableTotalScoreString(Score), Spacing = new Vector2(-1.5f), Font = OsuFont.Style.Subtitle.With(weight: FontWeight.Light, fixedWidth: true), Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, @@ -503,10 +504,10 @@ namespace osu.Game.Screens.SelectV2 private void updateModDisplay() { - if (score.Mods.Length > 0) + if (Score.Mods.Length > 0) { modsContainer.Padding = new MarginPadding { Top = 4f }; - modsContainer.ChildrenEnumerable = score.Mods.AsOrdered().Select(mod => new ModIcon(mod) + modsContainer.ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.3f), // trim mod icon height down to its true height for alignment purposes. @@ -608,7 +609,7 @@ namespace osu.Game.Screens.SelectV2 ITooltip IHasCustomTooltip.GetCustomTooltip() => new LeaderboardScoreTooltip(colourProvider); - ScoreInfo IHasCustomTooltip.TooltipContent => score; + ScoreInfo IHasCustomTooltip.TooltipContent => Score; MenuItem[] IHasContextMenu.ContextMenuItems { @@ -617,18 +618,18 @@ namespace osu.Game.Screens.SelectV2 List items = new List(); // system mods should never be copied across regardless of anything. - var copyableMods = score.Mods.Where(m => IsValidMod.Invoke(m) && m.Type != ModType.System).ToArray(); + var copyableMods = Score.Mods.Where(m => IsValidMod.Invoke(m) && m.Type != ModType.System).ToArray(); if (copyableMods.Length > 0) items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = copyableMods)); - if (score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{score.OnlineID}"))); + if (Score.OnlineID > 0) + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); - if (score.Files.Count <= 0) return items.ToArray(); + if (Score.Files.Count <= 0) return items.ToArray(); - items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(score))); - items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score))); + items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); return items.ToArray(); } diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index c0ccf0ab93..fdc61ad37e 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -269,7 +269,7 @@ namespace osu.Game.Screens.SelectV2 .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); } - private partial class SongSelectSearchTextBox : ShearedFilterTextBox + internal partial class SongSelectSearchTextBox : ShearedFilterTextBox { protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox(); diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/SelectV2/SoloSongSelect.cs index 4ef73d4c49..5c3e1453ea 100644 --- a/osu.Game/Screens/SelectV2/SoloSongSelect.cs +++ b/osu.Game/Screens/SelectV2/SoloSongSelect.cs @@ -61,7 +61,7 @@ namespace osu.Game.Screens.SelectV2 public override IEnumerable GetForwardActions(BeatmapInfo beatmap) { yield return new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => SelectAndRun(beatmap, OnStart)) { Icon = FontAwesome.Solid.Check }; - yield return new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => edit(beatmap)) { Icon = FontAwesome.Solid.PencilAlt }; + yield return new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(beatmap)) { Icon = FontAwesome.Solid.PencilAlt }; yield return new OsuMenuItemSpacer(); @@ -138,7 +138,7 @@ namespace osu.Game.Screens.SelectV2 } } - private void edit(BeatmapInfo beatmap) + public void Edit(BeatmapInfo beatmap) { if (!this.IsCurrentScreen()) return; diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index e26c72575a..feb7f21d61 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -670,7 +670,7 @@ namespace osu.Game.Screens.SelectV2 // This avoids a flicker of a placeholder or invalid beatmap before a proper selection. // // After the carousel finishes filtering, it will attempt a selection then call this method again. - if (!carouselItemsPresented && !checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo, filterControl.CreateCriteria())) + if (!CarouselItemsPresented && !checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo, filterControl.CreateCriteria())) return; if (carousel.VisuallyFocusSelected) @@ -703,7 +703,10 @@ namespace osu.Game.Screens.SelectV2 #region Filtering - private bool carouselItemsPresented; + /// + /// Whether the carousel has finished initial presentation of beatmap panels. + /// + public bool CarouselItemsPresented { get; private set; } private const double filter_delay = 250; @@ -727,7 +730,7 @@ namespace osu.Game.Screens.SelectV2 if (carousel.Criteria == null) return; - carouselItemsPresented = true; + CarouselItemsPresented = true; int count = carousel.MatchedBeatmapsCount; diff --git a/osu.Game/Tests/Visual/EditorSavingTestScene.cs b/osu.Game/Tests/Visual/EditorSavingTestScene.cs index 78188d7cf7..d2b216caa8 100644 --- a/osu.Game/Tests/Visual/EditorSavingTestScene.cs +++ b/osu.Game/Tests/Visual/EditorSavingTestScene.cs @@ -13,7 +13,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK.Input; namespace osu.Game.Tests.Visual @@ -74,15 +74,14 @@ namespace osu.Game.Tests.Visual AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); - SongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new PlaySongSelect()); - AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for carousel load", () => songSelect.CarouselItemsPresented); AddStep("Present same beatmap", () => Game.PresentBeatmap(Game.BeatmapManager.QueryBeatmapSet(set => set.ID == beatmapSetGuid)!.Value, beatmap => beatmap.ID == beatmapGuid)); AddUntilStep("Wait for beatmap selected", () => Game.Beatmap.Value.BeatmapInfo.ID == beatmapGuid); - AddStep("Open options", () => InputManager.Key(Key.F3)); - AddStep("Enter editor", () => InputManager.Key(Key.Number5)); + AddStep("Open editor", () => songSelect.Edit(Game.Beatmap.Value.BeatmapInfo)); AddUntilStep("Wait for editor load", () => Editor != null); } From 7fcbda6cd0cae36763f44301553258b038e0fa6d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Jun 2025 18:15:40 +0900 Subject: [PATCH 160/173] Fix mod select not being hidden when `CloseAllOverlays` is called --- osu.Game/OsuGame.cs | 2 ++ osu.Game/Screens/Footer/ScreenFooter.cs | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 394917dc62..767ad78b04 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -312,6 +312,8 @@ namespace osu.Game foreach (var overlay in focusedOverlays) overlay.Hide(); + ScreenFooter.ActiveOverlay?.Hide(); + if (hideToolbar) Toolbar.Hide(); } diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index ad3aaaa2c9..6d7a32d57a 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -226,20 +226,21 @@ namespace osu.Game.Screens.Footer } } - private ShearedOverlayContainer? activeOverlay; + public ShearedOverlayContainer? ActiveOverlay { get; private set; } + private VisibilityContainer? activeFooterContent; private readonly List temporarilyHiddenButtons = new List(); public IDisposable RegisterActiveOverlayContainer(ShearedOverlayContainer overlay, out VisibilityContainer? footerContent) { - if (activeOverlay != null) + if (ActiveOverlay != null) { throw new InvalidOperationException(@"Cannot set overlay content while one is already present. " + - $@"The previous overlay ({activeOverlay.GetType().Name}) should be hidden first."); + $@"The previous overlay ({ActiveOverlay.GetType().Name}) should be hidden first."); } - activeOverlay = overlay; + ActiveOverlay = overlay; Debug.Assert(temporarilyHiddenButtons.Count == 0); @@ -277,7 +278,7 @@ namespace osu.Game.Screens.Footer private void clearActiveOverlayContainer() { - if (activeOverlay == null) + if (ActiveOverlay == null) return; Debug.Assert(activeFooterContent != null); @@ -300,7 +301,7 @@ namespace osu.Game.Screens.Footer activeFooterContent.Delay(timeUntilRun).Expire(); activeFooterContent = null; - activeOverlay = null; + ActiveOverlay = null; } private void updateColourScheme(int hue) @@ -337,12 +338,12 @@ namespace osu.Game.Screens.Footer private void onBackPressed() { - if (activeOverlay != null) + if (ActiveOverlay != null) { - if (activeOverlay.OnBackButton()) + if (ActiveOverlay.OnBackButton()) return; - activeOverlay.Hide(); + ActiveOverlay.Hide(); return; } From 94d6260d9b37b492d14d1730f30042d7ee115493 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 24 Jun 2025 18:42:41 +0900 Subject: [PATCH 161/173] Remove incorrectly placed `ensureGlobalBeatmapValid` call This is being run in the flow where we are providing a specific beatmap for immediately selection. In an edge case scenario, the carousel may be pending on a filter operation, which would cause the whole `SelectAndRun` call to fail when it doesn't need to. This is reproduced by multiple test scenes. One example is `TestSceneOpenEditorTimestamp.TestErrorNotifications`. --- osu.Game/Screens/SelectV2/SongSelect.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index feb7f21d61..317ed743c0 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -449,9 +449,6 @@ namespace osu.Game.Screens.SelectV2 if (Beatmap.IsDefault) return; - if (!ensureGlobalBeatmapValid()) - return; - startAction(); } From 99220408f63044dbe85b22dda6c0e5630981237d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 17:34:52 +0900 Subject: [PATCH 162/173] Remove duplicated test method --- .../Navigation/TestSceneScreenNavigation.cs | 47 +------------------ 1 file changed, 2 insertions(+), 45 deletions(-) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index bcab3c7672..d50fc69823 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -251,51 +251,6 @@ namespace osu.Game.Tests.Visual.Navigation SoloSongSelect songSelect = null; double scrollPosition = 0; - AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); - AddUntilStep("wait for volume overlay to hide", () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Hidden)); - PushAndConfirm(() => songSelect = new SoloSongSelect()); - AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); - AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - - AddUntilStep("store scroll position", () => - { - double s = getCarouselScrollPosition(); - - // TODO: this logic can likely be removed when we fix https://github.com/ppy/osu/issues/33379 - if (scrollPosition == s) - return true; - - scrollPosition = s; - return false; - }); - - AddStep("move to left side", () => InputManager.MoveMouseTo( - songSelect.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre)); - AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); - AddAssert("carousel didn't move", getCarouselScrollPosition, () => Is.EqualTo(scrollPosition)); - - AddRepeatStep("alt-scroll down", () => - { - InputManager.PressKey(Key.AltLeft); - InputManager.ScrollVerticalBy(-1); - InputManager.ReleaseKey(Key.AltLeft); - }, 5); - AddAssert("game volume decreased", () => Game.Dependencies.Get().Get(FrameworkSetting.VolumeUniversal), () => Is.LessThan(1)); - - AddStep("move to carousel", () => InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single())); - AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); - AddAssert("carousel moved", getCarouselScrollPosition, () => Is.Not.EqualTo(scrollPosition)); - - double getCarouselScrollPosition() => Game.ChildrenOfType.CarouselScrollContainer>().Single().Current; - } - - [Test] - public void TestNewSongSelectScrollHandling() - { - SoloSongSelect songSelect = null; - double scrollPosition = 0; - AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); AddUntilStep("wait for volume overlay to hide", () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Hidden)); PushAndConfirm(() => songSelect = new SoloSongSelect()); @@ -303,6 +258,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddUntilStep("wait for beatmap", () => Game.ChildrenOfType().Any()); + // TODO: this logic can likely be removed when we fix https://github.com/ppy/osu/issues/33379 + // It should be probably be immediate in this case. AddWaitStep("wait for scroll", 10); AddStep("store scroll position", () => scrollPosition = getCarouselScrollPosition()); From 709bc9c33077d8c41ceda5079c31eb8ec7121aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Tue, 1 Jul 2025 10:38:22 +0200 Subject: [PATCH 163/173] Use OverlayColourProvider for BeatDivisorControl icon colour --- .../Screens/Edit/Compose/Components/BeatDivisorControl.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 5883b6d89d..da145f0994 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -373,11 +373,11 @@ namespace osu.Game.Screens.Edit.Compose.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { - IconColour = colours.GrayB; + IconColour = colourProvider.Light3; IconHoverColour = Color4.White; - HoverColour = colours.Gray7; + HoverColour = colours.Gray6; FlashColour = colours.Gray9; } } From 46dab76eba748926cafa0b0f85efdb25d8dcf4a7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 17:47:39 +0900 Subject: [PATCH 164/173] Fix footer not showing in first run overlay --- osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index aec1859176..edadc333c8 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -148,6 +148,8 @@ namespace osu.Game.Overlays.FirstRunSetup protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new DependencyContainer(new DependencyIsolationContainer(base.CreateChildDependencies(parent))); + private ScreenFooter footer; + [BackgroundDependencyLoader] private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { @@ -157,7 +159,6 @@ namespace osu.Game.Overlays.FirstRunSetup OsuScreenStack stack; OsuLogo logo; - ScreenFooter footer; Padding = new MarginPadding(5); @@ -195,6 +196,13 @@ namespace osu.Game.Overlays.FirstRunSetup // intentionally load synchronously so it is included in the initial load of the first run screen. stack.PushSynchronously(screen); } + + protected override void LoadComplete() + { + base.LoadComplete(); + + footer.Show(); + } } private class DependencyIsolationContainer : IReadOnlyDependencyContainer From 2c22158dbdaa13f3e27622e1df4317536e94300e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 1 Jul 2025 11:23:55 +0200 Subject: [PATCH 165/173] Move external skin edit overlay out of `OsuGame` --- osu.Game/OsuGame.cs | 1 - .../Overlays/SkinEditor/SkinEditorOverlay.cs | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 9e524878dc..394917dc62 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1225,7 +1225,6 @@ namespace osu.Game loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true); - loadComponentSingleFile(new ExternalEditOverlay(), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 344dcc0d66..7553c83056 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -49,9 +49,15 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private IPerformFromScreenRunner? performer { get; set; } + [Resolved] + private IOverlayManager? overlayManager { get; set; } + [Cached] public readonly EditorClipboard Clipboard = new EditorClipboard(); + [Cached] + private readonly ExternalEditOverlay externalEditOverlay = new ExternalEditOverlay(); + [Resolved] private OsuGame game { get; set; } = null!; @@ -69,6 +75,7 @@ namespace osu.Game.Overlays.SkinEditor private OsuScreen? lastTargetScreen; private InvokeOnDisposal? nestedInputManagerDisable; + private IDisposable? externalEditOverlayRegistration; private readonly LayoutValue drawSizeLayout; @@ -86,6 +93,13 @@ namespace osu.Game.Overlays.SkinEditor config.BindWith(OsuSetting.BeatmapSkins, beatmapSkins); } + protected override void LoadComplete() + { + base.LoadComplete(); + + externalEditOverlayRegistration = overlayManager?.RegisterBlockingOverlay(externalEditOverlay); + } + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) @@ -342,6 +356,14 @@ namespace osu.Game.Overlays.SkinEditor base.ToggleVisibility(); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + externalEditOverlayRegistration?.Dispose(); + externalEditOverlayRegistration = null; + } + private partial class EndlessPlayer : ReplayPlayer { protected override UserActivity? InitialActivity => null; From 142984c07f28394ad64aaea9edbd0ecd48804e3b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 18:58:15 +0900 Subject: [PATCH 166/173] Change audio ducking at song select v2 to be temporary to avoid conflict with overlays Closes https://github.com/ppy/osu/issues/33539. --- osu.Game/Screens/SelectV2/SongSelect.cs | 35 +++++++++++-------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index e26c72575a..2f26f123de 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -402,22 +402,6 @@ namespace osu.Game.Screens.SelectV2 private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) => beatmap.PrepareTrackForPreview(true); - private IDisposable? trackDuck; - - private void attachTrackDuckingIfShould() - { - bool shouldDuck = noResultsPlaceholder.State.Value == Visibility.Visible; - - if (shouldDuck && trackDuck == null) - trackDuck = music.Duck(new DuckParameters { DuckVolumeTo = 1, DuckCutoffTo = 500 }); - } - - private void detachTrackDucking() - { - trackDuck?.Dispose(); - trackDuck = null; - } - #endregion #region Selection handling @@ -604,7 +588,6 @@ namespace osu.Game.Screens.SelectV2 updateWedgeVisibility(); beginLooping(); - attachTrackDuckingIfShould(); ensureGlobalBeatmapValid(); @@ -622,7 +605,6 @@ namespace osu.Game.Screens.SelectV2 updateWedgeVisibility(); endLooping(); - detachTrackDucking(); } protected override void LogoArriving(OsuLogo logo, bool resuming) @@ -748,17 +730,30 @@ namespace osu.Game.Screens.SelectV2 if (count == 0) { + if (noResultsPlaceholder.State.Value == Visibility.Hidden) + { + // Duck audio temporarily when the no results placeholder becomes visible. + // + // Temporary ducking makes it easier to avoid scenarios where the ducking interacts badly + // with other global UI components (like overlays). + music.DuckMomentarily(400, new DuckParameters + { + DuckVolumeTo = 1, + DuckCutoffTo = 500, + DuckDuration = 250, + RestoreDuration = 2000, + }); + } + noResultsPlaceholder.Show(); noResultsPlaceholder.Filter = carousel.Criteria!; - attachTrackDuckingIfShould(); rightGradientBackground.ResizeWidthTo(3, 1000, Easing.OutPow10); } else { noResultsPlaceholder.Hide(); - detachTrackDucking(); rightGradientBackground.ResizeWidthTo(1, 400, Easing.OutPow10); } } From 68ab89a8f1d0c15f1ea35afa82fedd9ba73ff4e6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 19:19:18 +0900 Subject: [PATCH 167/173] Add note about `LATEST_VERSION` cross-project usage --- osu.Game/Beatmaps/Formats/LegacyDecoder.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 6c290c4f1c..6fb762b9ee 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -16,6 +16,8 @@ namespace osu.Game.Beatmaps.Formats public abstract class LegacyDecoder : Decoder where T : new() { + // If this is updated, a new release of `osu-server-beatmap-submission` is required with updated packages. + // See usage at https://github.com/ppy/osu-server-beatmap-submission/blob/master/osu.Server.BeatmapSubmission/Services/BeatmapPackageParser.cs#L96-L97. public const int LATEST_VERSION = 14; public const int MAX_COMBO_COLOUR_COUNT = 8; From ed189fecf4287142b5094672d7aad7a5a33179b2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 19:30:47 +0900 Subject: [PATCH 168/173] Make `ShearedButton` block mouse down events Closes https://github.com/ppy/osu/issues/33748. I (and tests) can't find any regressions from this. One would hope we aren't relying on fall-through mouse down anywhere beneath buttons.. --- osu.Game/Graphics/UserInterface/ShearedButton.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index 16891babf3..2047fc74f4 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.cs @@ -179,7 +179,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnMouseDown(MouseDownEvent e) { Content.ScaleTo(0.9f, 2000, Easing.OutQuint); - return base.OnMouseDown(e); + return true; } protected override void OnMouseUp(MouseUpEvent e) From 5f48124a94f673a58a5269ad69ed442079741c2e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 19:47:33 +0900 Subject: [PATCH 169/173] Also fix leaderboard scores not eating mouse down Closes second portion of https://github.com/ppy/osu/issues/33748. --- osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs index 10917f08ac..0554b1b815 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -82,6 +83,13 @@ namespace osu.Game.Screens.SelectV2 private const float personal_best_height = 112; + // Blocking mouse down is required to avoid song select's background reveal logic happening while hovering scores. + // Our horizontal alignment doesn't really align with the rest of the sheared components (protrudes a touch to the right) which makes + // it complicated to handle this at a higher level. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => scoresScroll.ReceivePositionalInputAt(screenSpacePos); + + protected override bool OnMouseDown(MouseDownEvent e) => true; + [BackgroundDependencyLoader] private void load() { From 2aca8eecb9cc926f7ec6863d832b4e14ab0df645 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 19:56:13 +0900 Subject: [PATCH 170/173] Fix footer appearing at loader screen on quick retries --- osu.Game/Screens/Play/PlayerLoader.cs | 16 ++++++++-------- osu.Game/Screens/SelectV2/SoloSongSelect.cs | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index f1a31b809f..1d73f7c0e1 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.Play // this makes the game stay in portrait mode when restarting gameplay rather than switching back to landscape. public override bool RequiresPortraitOrientation => CurrentPlayer?.RequiresPortraitOrientation == true; - public override float BackgroundParallaxAmount => quickRestart ? 0 : 1; + public override float BackgroundParallaxAmount => QuickRestart ? 0 : 1; // Here because IsHovered will not update unless we do so. public override bool HandlePositionalInput => true; @@ -158,7 +158,7 @@ namespace osu.Game.Screens.Play private PlayerLoaderDisclaimer? epilepsyWarning; - private bool quickRestart; + protected bool QuickRestart { get; private set; } private IDisposable? highPerformanceSession; @@ -380,7 +380,7 @@ namespace osu.Game.Screens.Play logo.ScaleTo(new Vector2(0.15f), duration, Easing.OutQuint); - if (quickRestart) + if (QuickRestart) { logo.Delay(quick_restart_initial_delay) .FadeIn(350); @@ -430,7 +430,7 @@ namespace osu.Game.Screens.Play // We need to perform this check here rather than in OnHover as any number of children of VisualSettings // may also be handling the hover events. - if (inputManager.HoveredDrawables.Contains(VisualSettings) || quickRestart) + if (inputManager.HoveredDrawables.Contains(VisualSettings) || QuickRestart) { // Preview user-defined background dim and blur when hovered on the visual settings panel. ApplyToBackground(b => @@ -469,7 +469,7 @@ namespace osu.Game.Screens.Play return; CurrentPlayer = createPlayer(); - CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart; + CurrentPlayer.Configuration.AutomaticallySkipIntro |= QuickRestart; CurrentPlayer.RestartCount = restartCount++; CurrentPlayer.PrepareLoaderForRestart = prepareForRestart; @@ -486,7 +486,7 @@ namespace osu.Game.Screens.Play private void prepareForRestart(bool quickRestartRequested) { - quickRestart = quickRestartRequested; + QuickRestart = quickRestartRequested; hideOverlays = true; ValidForResume = true; } @@ -495,7 +495,7 @@ namespace osu.Game.Screens.Play { MetadataInfo.Loading = true; - if (quickRestart) + if (QuickRestart) { BackButtonVisibility.Value = false; @@ -635,7 +635,7 @@ namespace osu.Game.Screens.Play }, // When a quick restart is activated, the metadata content will display some time later if it's taking too long. // To avoid it appearing too briefly, if it begins to fade in let's induce a standard delay. - quickRestart && content.Alpha == 0 ? 0 : 500); + QuickRestart && content.Alpha == 0 ? 0 : 500); } private void cancelLoad() diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/SelectV2/SoloSongSelect.cs index 4ef73d4c49..1c8f041bfe 100644 --- a/osu.Game/Screens/SelectV2/SoloSongSelect.cs +++ b/osu.Game/Screens/SelectV2/SoloSongSelect.cs @@ -173,7 +173,7 @@ namespace osu.Game.Screens.SelectV2 private partial class PlayerLoader : Play.PlayerLoader { - public override bool ShowFooter => true; + public override bool ShowFooter => !QuickRestart; public PlayerLoader(Func createPlayer) : base(createPlayer) From 5b584227a0abfe9d6eee3e79a653dd0d559a6aa0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 19:56:52 +0900 Subject: [PATCH 171/173] Fix back button appearing on second quick retry when it shouldn't --- osu.Game/Screens/Play/PlayerLoader.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 1d73f7c0e1..848b8292d4 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -98,6 +98,8 @@ namespace osu.Game.Screens.Play private Box? quickRestartBlackLayer; + private ScheduledDelegate? quickRestartBackButtonRestore; + [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); @@ -518,7 +520,8 @@ namespace osu.Game.Screens.Play .ScaleTo(1) .FadeInFromZero(500, Easing.OutQuint); - this.Delay(quick_restart_initial_delay).Schedule(() => BackButtonVisibility.Value = true); + quickRestartBackButtonRestore?.Cancel(); + quickRestartBackButtonRestore = Scheduler.AddDelayed(() => BackButtonVisibility.Value = true, quick_restart_initial_delay); } else { From 01f5068535a468bb502bfafc91ef16684b9bb5a8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 1 Jul 2025 20:19:46 +0900 Subject: [PATCH 172/173] Fix potential test deadlock Disposal woes. --- osu.Game/OsuGame.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 767ad78b04..57ed6a5dbf 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -227,6 +227,8 @@ namespace osu.Game private Bindable configSkin; + private RealmDetachedBeatmapStore detachedBeatmapStore; + private readonly string[] args; private readonly List focusedOverlays = new List(); @@ -1002,6 +1004,10 @@ namespace osu.Game protected override void Dispose(bool isDisposing) { + // Without this, tests may deadlock due to cancellation token not becoming cancelled before disposal. + // To reproduce, run `TestSceneButtonSystemNavigation` ensuring `TestConstructor` runs before `TestFastShortcutKeys`. + detachedBeatmapStore?.Dispose(); + base.Dispose(isDisposing); SentryLogger.Dispose(); } @@ -1245,7 +1251,7 @@ namespace osu.Game loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); - loadComponentSingleFile(new RealmDetachedBeatmapStore(), Add, true); + loadComponentSingleFile(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); From 08a3edcb5cadb21dc4a15dc775440f2339533ec3 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 2 Jul 2025 14:47:22 +0900 Subject: [PATCH 173/173] Remove comment referencing removed line --- osu.Game/Screens/SelectV2/SongSelect.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 317ed743c0..25f3b160c0 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -434,8 +434,6 @@ namespace osu.Game.Screens.SelectV2 if (!this.IsCurrentScreen()) return; - // `ensureGlobalBeatmapValid` also performs this checks, but it will change the active selection on fail. - // By checking locally first, we can correctly perform a no-op rather than changing selection. if (!checkBeatmapValidForSelection(beatmap, carousel.Criteria)) return;