From 498d93be614cfd9057ca9c3af0ff63f4c581b3f2 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sat, 3 Feb 2024 16:59:48 +0100 Subject: [PATCH 01/58] Add way to associate with files and URIs on Windows --- .../TestSceneWindowsAssociationManager.cs | 106 +++++++ .../WindowsAssociationManagerStrings.cs | 39 +++ osu.Game/Updater/WindowsAssociationManager.cs | 268 ++++++++++++++++++ osu.sln.DotSettings | 1 + 4 files changed, 414 insertions(+) create mode 100644 osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs create mode 100644 osu.Game/Localisation/WindowsAssociationManagerStrings.cs create mode 100644 osu.Game/Updater/WindowsAssociationManager.cs diff --git a/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs new file mode 100644 index 0000000000..72256860fd --- /dev/null +++ b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs @@ -0,0 +1,106 @@ +// 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.IO; +using System.Runtime.Versioning; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Platform; +using osu.Game.Graphics.Sprites; +using osu.Game.Tests.Resources; +using osu.Game.Updater; + +namespace osu.Game.Tests.Visual.Updater +{ + [SupportedOSPlatform("windows")] + [Ignore("These tests modify the windows registry and open programs")] + public partial class TestSceneWindowsAssociationManager : OsuTestScene + { + private static readonly string exe_path = Path.ChangeExtension(typeof(TestSceneWindowsAssociationManager).Assembly.Location, ".exe"); + + [Resolved] + private GameHost host { get; set; } = null!; + + private readonly WindowsAssociationManager associationManager; + + public TestSceneWindowsAssociationManager() + { + Children = new Drawable[] + { + new OsuSpriteText { Text = Environment.CommandLine }, + associationManager = new WindowsAssociationManager(exe_path, "osu.Test"), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (Environment.CommandLine.Contains(".osz", StringComparison.Ordinal)) + ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkOliveGreen)); + + if (Environment.CommandLine.Contains("osu://", StringComparison.Ordinal)) + ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkBlue)); + + if (Environment.CommandLine.Contains("osump://", StringComparison.Ordinal)) + ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkRed)); + } + + [Test] + public void TestInstall() + { + AddStep("install", () => associationManager.InstallAssociations()); + } + + [Test] + public void TestOpenBeatmap() + { + string beatmapPath = null!; + AddStep("create temp beatmap", () => beatmapPath = TestResources.GetTestBeatmapForImport()); + AddAssert("beatmap path ends with .osz", () => beatmapPath, () => Does.EndWith(".osz")); + AddStep("open beatmap", () => host.OpenFileExternally(beatmapPath)); + AddUntilStep("wait for focus", () => host.IsActive.Value); + AddStep("delete temp beatmap", () => File.Delete(beatmapPath)); + } + + /// + /// To check that the icon is correct + /// + [Test] + public void TestPresentBeatmap() + { + string beatmapPath = null!; + AddStep("create temp beatmap", () => beatmapPath = TestResources.GetTestBeatmapForImport()); + AddAssert("beatmap path ends with .osz", () => beatmapPath, () => Does.EndWith(".osz")); + AddStep("show beatmap in explorer", () => host.PresentFileExternally(beatmapPath)); + AddUntilStep("wait for focus", () => host.IsActive.Value); + AddStep("delete temp beatmap", () => File.Delete(beatmapPath)); + } + + [TestCase("osu://s/1")] + [TestCase("osump://123")] + public void TestUrl(string url) + { + AddStep($"open {url}", () => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true })); + } + + [Test] + public void TestUninstall() + { + AddStep("uninstall", () => associationManager.UninstallAssociations()); + } + + /// + /// Useful when testing things out and manually changing the registry. + /// + [Test] + public void TestNotifyShell() + { + AddStep("notify shell of changes", () => associationManager.NotifyShellUpdate()); + } + } +} diff --git a/osu.Game/Localisation/WindowsAssociationManagerStrings.cs b/osu.Game/Localisation/WindowsAssociationManagerStrings.cs new file mode 100644 index 0000000000..95a6decdd6 --- /dev/null +++ b/osu.Game/Localisation/WindowsAssociationManagerStrings.cs @@ -0,0 +1,39 @@ +// 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 WindowsAssociationManagerStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.WindowsAssociationManager"; + + /// + /// "osu! Beatmap" + /// + public static LocalisableString OsuBeatmap => new TranslatableString(getKey(@"osu_beatmap"), @"osu! Beatmap"); + + /// + /// "osu! Replay" + /// + public static LocalisableString OsuReplay => new TranslatableString(getKey(@"osu_replay"), @"osu! Replay"); + + /// + /// "osu! Skin" + /// + public static LocalisableString OsuSkin => new TranslatableString(getKey(@"osu_skin"), @"osu! Skin"); + + /// + /// "osu!" + /// + public static LocalisableString OsuProtocol => new TranslatableString(getKey(@"osu_protocol"), @"osu!"); + + /// + /// "osu! Multiplayer" + /// + public static LocalisableString OsuMultiplayer => new TranslatableString(getKey(@"osu_multiplayer"), @"osu! Multiplayer"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Updater/WindowsAssociationManager.cs b/osu.Game/Updater/WindowsAssociationManager.cs new file mode 100644 index 0000000000..8949d88362 --- /dev/null +++ b/osu.Game/Updater/WindowsAssociationManager.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.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Resources.Icons; +using osu.Game.Localisation; + +namespace osu.Game.Updater +{ + [SupportedOSPlatform("windows")] + public partial class WindowsAssociationManager : Component + { + public const string SOFTWARE_CLASSES = @"Software\Classes"; + + /// + /// Sub key for setting the icon. + /// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon + /// + public const string DEFAULT_ICON = @"DefaultIcon"; + + /// + /// Sub key for setting the command line that the shell invokes. + /// https://learn.microsoft.com/en-us/windows/win32/com/shell + /// + public const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; + + private static readonly FileAssociation[] file_associations = + { + new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), + new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), + new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Lazer), + new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Lazer), + }; + + private static readonly UriAssociation[] uri_associations = + { + new UriAssociation(@"osu", WindowsAssociationManagerStrings.OsuProtocol, Icons.Lazer), + new UriAssociation(@"osump", WindowsAssociationManagerStrings.OsuMultiplayer, Icons.Lazer), + }; + + [Resolved] + private LocalisationManager localisation { get; set; } = null!; + + private IBindable localisationParameters = null!; + + private readonly string exePath; + private readonly string programIdPrefix; + + /// Path to the executable to register. + /// + /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, + /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. + /// + public WindowsAssociationManager(string exePath, string programIdPrefix) + { + this.exePath = exePath; + this.programIdPrefix = programIdPrefix; + } + + [BackgroundDependencyLoader] + private void load() + { + localisationParameters = localisation.CurrentParameters.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + localisationParameters.ValueChanged += _ => updateDescriptions(); + } + + internal void InstallAssociations() + { + try + { + using (var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, writable: true)) + { + if (classes == null) + return; + + foreach (var association in file_associations) + association.Install(classes, exePath, programIdPrefix); + + foreach (var association in uri_associations) + association.Install(classes, exePath); + } + + updateDescriptions(); + } + catch (Exception e) + { + Logger.Log(@$"Failed to install file and URI associations: {e.Message}"); + } + } + + private void updateDescriptions() + { + try + { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; + + foreach (var association in file_associations) + { + var b = localisation.GetLocalisedBindableString(association.Description); + association.UpdateDescription(classes, programIdPrefix, b.Value); + b.UnbindAll(); + } + + foreach (var association in uri_associations) + { + var b = localisation.GetLocalisedBindableString(association.Description); + association.UpdateDescription(classes, b.Value); + b.UnbindAll(); + } + + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log($@"Failed to update file and URI associations: {e.Message}"); + } + } + + internal void UninstallAssociations() + { + try + { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; + + foreach (var association in file_associations) + association.Uninstall(classes, programIdPrefix); + + foreach (var association in uri_associations) + association.Uninstall(classes); + + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log($@"Failed to uninstall file and URI associations: {e.Message}"); + } + } + + internal void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + + #region Native interop + + [DllImport("Shell32.dll")] + private static extern void SHChangeNotify(EventId wEventId, Flags uFlags, IntPtr dwItem1, IntPtr dwItem2); + + private enum EventId + { + /// + /// A file type association has changed. must be specified in the uFlags parameter. + /// dwItem1 and dwItem2 are not used and must be . This event should also be sent for registered protocols. + /// + SHCNE_ASSOCCHANGED = 0x08000000 + } + + private enum Flags : uint + { + SHCNF_IDLIST = 0x0000 + } + + #endregion + + private record FileAssociation(string Extension, LocalisableString Description, Win32Icon Icon) + { + private string getProgramId(string prefix) => $@"{prefix}.File{Extension}"; + + /// + /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key + /// + public void Install(RegistryKey classes, string exePath, string programIdPrefix) + { + string programId = getProgramId(programIdPrefix); + + // register a program id for the given extension + using (var programKey = classes.CreateSubKey(programId)) + { + using (var defaultIconKey = programKey.CreateSubKey(DEFAULT_ICON)) + defaultIconKey.SetValue(null, Icon.RegistryString); + + using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) + openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); + } + + using (var extensionKey = classes.CreateSubKey(Extension)) + { + // set ourselves as the default program + extensionKey.SetValue(null, programId); + + // add to the open with dialog + // https://learn.microsoft.com/en-us/windows/win32/shell/how-to-include-an-application-on-the-open-with-dialog-box + using (var openWithKey = extensionKey.CreateSubKey(@"OpenWithProgIds")) + openWithKey.SetValue(programId, string.Empty); + } + } + + public void UpdateDescription(RegistryKey classes, string programIdPrefix, string description) + { + using (var programKey = classes.OpenSubKey(getProgramId(programIdPrefix), true)) + programKey?.SetValue(null, description); + } + + public void Uninstall(RegistryKey classes, string programIdPrefix) + { + string programId = getProgramId(programIdPrefix); + + // importantly, we don't delete the default program entry because some other program could have taken it. + + using (var extensionKey = classes.OpenSubKey($@"{Extension}\OpenWithProgIds", true)) + extensionKey?.DeleteValue(programId, throwOnMissingValue: false); + + classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false); + } + } + + private record UriAssociation(string Protocol, LocalisableString Description, Win32Icon Icon) + { + /// + /// "The URL Protocol string value indicates that this key declares a custom pluggable protocol handler." + /// See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). + /// + public const string URL_PROTOCOL = @"URL Protocol"; + + /// + /// Registers an URI protocol handler in accordance with https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). + /// + public void Install(RegistryKey classes, string exePath) + { + using (var protocolKey = classes.CreateSubKey(Protocol)) + { + protocolKey.SetValue(URL_PROTOCOL, string.Empty); + + using (var defaultIconKey = protocolKey.CreateSubKey(DEFAULT_ICON)) + defaultIconKey.SetValue(null, Icon.RegistryString); + + using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) + openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); + } + } + + public void UpdateDescription(RegistryKey classes, string description) + { + using (var protocolKey = classes.OpenSubKey(Protocol, true)) + protocolKey?.SetValue(null, $@"URL:{description}"); + } + + public void Uninstall(RegistryKey classes) + { + classes.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); + } + } + } +} diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 1bf8aa7b0b..e2e28c38ec 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -1005,6 +1005,7 @@ private void load() True True True + True True True True From 03578821c0c2c9fc4b4832208e7c10751f8b28af Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sat, 3 Feb 2024 17:04:06 +0100 Subject: [PATCH 02/58] Associate on startup --- osu.Desktop/OsuGameDesktop.cs | 7 ++++++- osu.Game/Updater/WindowsAssociationManager.cs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index a0db896f46..a048deddb3 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -134,9 +134,14 @@ namespace osu.Desktop LoadComponentAsync(new DiscordRichPresence(), Add); - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows && OperatingSystem.IsWindows()) + { LoadComponentAsync(new GameplayWinKeyBlocker(), Add); + string? executableLocation = Path.GetDirectoryName(typeof(OsuGameDesktop).Assembly.Location); + LoadComponentAsync(new WindowsAssociationManager(Path.Join(executableLocation, @"osu!.exe"), "osu"), Add); + } + LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); diff --git a/osu.Game/Updater/WindowsAssociationManager.cs b/osu.Game/Updater/WindowsAssociationManager.cs index 8949d88362..104406c81b 100644 --- a/osu.Game/Updater/WindowsAssociationManager.cs +++ b/osu.Game/Updater/WindowsAssociationManager.cs @@ -69,6 +69,7 @@ namespace osu.Game.Updater private void load() { localisationParameters = localisation.CurrentParameters.GetBoundCopy(); + InstallAssociations(); } protected override void LoadComplete() From 2bac09ee00d1e809e38aca97350e4853b2ebf09f Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sat, 3 Feb 2024 17:23:59 +0100 Subject: [PATCH 03/58] Only associate on a deployed build Helps to reduce clutter when developing --- osu.Desktop/OsuGameDesktop.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index a048deddb3..c5175fd549 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -134,10 +134,11 @@ namespace osu.Desktop LoadComponentAsync(new DiscordRichPresence(), Add); - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows && OperatingSystem.IsWindows()) - { + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) LoadComponentAsync(new GameplayWinKeyBlocker(), Add); + if (OperatingSystem.IsWindows() && IsDeployedBuild) + { string? executableLocation = Path.GetDirectoryName(typeof(OsuGameDesktop).Assembly.Location); LoadComponentAsync(new WindowsAssociationManager(Path.Join(executableLocation, @"osu!.exe"), "osu"), Add); } From cdcf5bddda5c15c518a810af6b68b06ab214ee52 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sat, 3 Feb 2024 17:56:14 +0100 Subject: [PATCH 04/58] Uninstall associations when uninstalling from squirrel --- osu.Desktop/Program.cs | 2 ++ .../Visual/Updater/TestSceneWindowsAssociationManager.cs | 2 +- osu.Game/Updater/WindowsAssociationManager.cs | 8 +++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index a7453dc0e0..c9ce5ebf1b 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -12,6 +12,7 @@ using osu.Framework.Platform; using osu.Game; using osu.Game.IPC; using osu.Game.Tournament; +using osu.Game.Updater; using SDL2; using Squirrel; @@ -180,6 +181,7 @@ namespace osu.Desktop { tools.RemoveShortcutForThisExe(); tools.RemoveUninstallerRegistryEntry(); + WindowsAssociationManager.UninstallAssociations(@"osu"); }, onEveryRun: (_, _, _) => { // While setting the `ProcessAppUserModelId` fixes duplicate icons/shortcuts on the taskbar, it currently diff --git a/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs index 72256860fd..f3eb468334 100644 --- a/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs +++ b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs @@ -100,7 +100,7 @@ namespace osu.Game.Tests.Visual.Updater [Test] public void TestNotifyShell() { - AddStep("notify shell of changes", () => associationManager.NotifyShellUpdate()); + AddStep("notify shell of changes", WindowsAssociationManager.NotifyShellUpdate); } } } diff --git a/osu.Game/Updater/WindowsAssociationManager.cs b/osu.Game/Updater/WindowsAssociationManager.cs index 104406c81b..bb0e37a2f4 100644 --- a/osu.Game/Updater/WindowsAssociationManager.cs +++ b/osu.Game/Updater/WindowsAssociationManager.cs @@ -78,7 +78,7 @@ namespace osu.Game.Updater localisationParameters.ValueChanged += _ => updateDescriptions(); } - internal void InstallAssociations() + public void InstallAssociations() { try { @@ -132,7 +132,9 @@ namespace osu.Game.Updater } } - internal void UninstallAssociations() + public void UninstallAssociations() => UninstallAssociations(programIdPrefix); + + public static void UninstallAssociations(string programIdPrefix) { try { @@ -154,7 +156,7 @@ namespace osu.Game.Updater } } - internal void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + internal static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); #region Native interop From 2f4211249e0b3863d6058252ed55dc0e4f9d6c7f Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 5 Feb 2024 13:12:03 +0100 Subject: [PATCH 05/58] Use cleaner way to specify .exe path --- osu.Desktop/OsuGameDesktop.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index c5175fd549..a6d9ff1653 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -138,10 +138,7 @@ namespace osu.Desktop LoadComponentAsync(new GameplayWinKeyBlocker(), Add); if (OperatingSystem.IsWindows() && IsDeployedBuild) - { - string? executableLocation = Path.GetDirectoryName(typeof(OsuGameDesktop).Assembly.Location); - LoadComponentAsync(new WindowsAssociationManager(Path.Join(executableLocation, @"osu!.exe"), "osu"), Add); - } + LoadComponentAsync(new WindowsAssociationManager(Path.ChangeExtension(typeof(OsuGameDesktop).Assembly.Location, ".exe"), "osu"), Add); LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); From 01efd1b353f556795f5d30eb4fe6723b7fcd6200 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 5 Feb 2024 13:34:03 +0100 Subject: [PATCH 06/58] Move `WindowsAssociationManager` to `osu.Desktop` --- osu.Desktop/Program.cs | 2 +- .../Windows}/WindowsAssociationManager.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename {osu.Game/Updater => osu.Desktop/Windows}/WindowsAssociationManager.cs (99%) diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index c9ce5ebf1b..edbf39a30a 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Runtime.Versioning; using osu.Desktop.LegacyIpc; +using osu.Desktop.Windows; using osu.Framework; using osu.Framework.Development; using osu.Framework.Logging; @@ -12,7 +13,6 @@ using osu.Framework.Platform; using osu.Game; using osu.Game.IPC; using osu.Game.Tournament; -using osu.Game.Updater; using SDL2; using Squirrel; diff --git a/osu.Game/Updater/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs similarity index 99% rename from osu.Game/Updater/WindowsAssociationManager.cs rename to osu.Desktop/Windows/WindowsAssociationManager.cs index bb0e37a2f4..038788f990 100644 --- a/osu.Game/Updater/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -13,7 +13,7 @@ using osu.Framework.Logging; using osu.Game.Resources.Icons; using osu.Game.Localisation; -namespace osu.Game.Updater +namespace osu.Desktop.Windows { [SupportedOSPlatform("windows")] public partial class WindowsAssociationManager : Component From 7789cc01eb31733fa76147adecf73db186c12759 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 5 Feb 2024 14:03:16 +0100 Subject: [PATCH 07/58] Copy .ico files when publishing These icons should appear in end-user installation folder. --- osu.Desktop/Windows/Icons.cs | 10 ++++++++++ osu.Desktop/Windows/Win32Icon.cs | 16 ++++++++++++++++ osu.Desktop/Windows/WindowsAssociationManager.cs | 5 ++--- osu.Desktop/osu.Desktop.csproj | 3 +++ 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 osu.Desktop/Windows/Icons.cs create mode 100644 osu.Desktop/Windows/Win32Icon.cs diff --git a/osu.Desktop/Windows/Icons.cs b/osu.Desktop/Windows/Icons.cs new file mode 100644 index 0000000000..cc60f92810 --- /dev/null +++ b/osu.Desktop/Windows/Icons.cs @@ -0,0 +1,10 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Desktop.Windows +{ + public static class Icons + { + public static Win32Icon Lazer => new Win32Icon(@"lazer.ico"); + } +} diff --git a/osu.Desktop/Windows/Win32Icon.cs b/osu.Desktop/Windows/Win32Icon.cs new file mode 100644 index 0000000000..9544846c55 --- /dev/null +++ b/osu.Desktop/Windows/Win32Icon.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Desktop.Windows +{ + public record Win32Icon + { + public readonly string Path; + + internal Win32Icon(string name) + { + string dir = System.IO.Path.GetDirectoryName(typeof(Win32Icon).Assembly.Location)!; + Path = System.IO.Path.Join(dir, name); + } + } +} diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 038788f990..a5f977d15d 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Framework.Logging; -using osu.Game.Resources.Icons; using osu.Game.Localisation; namespace osu.Desktop.Windows @@ -194,7 +193,7 @@ namespace osu.Desktop.Windows using (var programKey = classes.CreateSubKey(programId)) { using (var defaultIconKey = programKey.CreateSubKey(DEFAULT_ICON)) - defaultIconKey.SetValue(null, Icon.RegistryString); + defaultIconKey.SetValue(null, Icon.Path); using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); @@ -249,7 +248,7 @@ namespace osu.Desktop.Windows protocolKey.SetValue(URL_PROTOCOL, string.Empty); using (var defaultIconKey = protocolKey.CreateSubKey(DEFAULT_ICON)) - defaultIconKey.SetValue(null, Icon.RegistryString); + defaultIconKey.SetValue(null, Icon.Path); using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index d6a11fa924..c6a95c1623 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -31,4 +31,7 @@ + + + From 4ec9d26657167bc7310984f093837ef183cef24f Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 5 Feb 2024 14:16:35 +0100 Subject: [PATCH 08/58] Inline constants --- osu.Desktop/OsuGameDesktop.cs | 2 +- .../Windows/WindowsAssociationManager.cs | 31 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index a6d9ff1653..2e1b34fb38 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -138,7 +138,7 @@ namespace osu.Desktop LoadComponentAsync(new GameplayWinKeyBlocker(), Add); if (OperatingSystem.IsWindows() && IsDeployedBuild) - LoadComponentAsync(new WindowsAssociationManager(Path.ChangeExtension(typeof(OsuGameDesktop).Assembly.Location, ".exe"), "osu"), Add); + LoadComponentAsync(new WindowsAssociationManager(), Add); LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index a5f977d15d..7131067224 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.IO; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.Win32; @@ -31,6 +32,14 @@ namespace osu.Desktop.Windows /// public const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; + public static readonly string EXE_PATH = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe"); + + /// + /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, + /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. + /// + public const string PROGRAM_ID_PREFIX = "osu"; + private static readonly FileAssociation[] file_associations = { new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), @@ -50,20 +59,6 @@ namespace osu.Desktop.Windows private IBindable localisationParameters = null!; - private readonly string exePath; - private readonly string programIdPrefix; - - /// Path to the executable to register. - /// - /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, - /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. - /// - public WindowsAssociationManager(string exePath, string programIdPrefix) - { - this.exePath = exePath; - this.programIdPrefix = programIdPrefix; - } - [BackgroundDependencyLoader] private void load() { @@ -87,10 +82,10 @@ namespace osu.Desktop.Windows return; foreach (var association in file_associations) - association.Install(classes, exePath, programIdPrefix); + association.Install(classes, EXE_PATH, PROGRAM_ID_PREFIX); foreach (var association in uri_associations) - association.Install(classes, exePath); + association.Install(classes, EXE_PATH); } updateDescriptions(); @@ -112,7 +107,7 @@ namespace osu.Desktop.Windows foreach (var association in file_associations) { var b = localisation.GetLocalisedBindableString(association.Description); - association.UpdateDescription(classes, programIdPrefix, b.Value); + association.UpdateDescription(classes, PROGRAM_ID_PREFIX, b.Value); b.UnbindAll(); } @@ -131,7 +126,7 @@ namespace osu.Desktop.Windows } } - public void UninstallAssociations() => UninstallAssociations(programIdPrefix); + public void UninstallAssociations() => UninstallAssociations(PROGRAM_ID_PREFIX); public static void UninstallAssociations(string programIdPrefix) { From 0168ade2e13f200f475081d54363b8b9ee835687 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 5 Feb 2024 14:19:22 +0100 Subject: [PATCH 09/58] Remove tests Can be tested with ``` dotnet publish -f net6.0 -r win-x64 osu.Desktop -o publish -c Debug publish\osu! ``` --- .../TestSceneWindowsAssociationManager.cs | 106 ------------------ 1 file changed, 106 deletions(-) delete mode 100644 osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs diff --git a/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs deleted file mode 100644 index f3eb468334..0000000000 --- a/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs +++ /dev/null @@ -1,106 +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.IO; -using System.Runtime.Versioning; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Platform; -using osu.Game.Graphics.Sprites; -using osu.Game.Tests.Resources; -using osu.Game.Updater; - -namespace osu.Game.Tests.Visual.Updater -{ - [SupportedOSPlatform("windows")] - [Ignore("These tests modify the windows registry and open programs")] - public partial class TestSceneWindowsAssociationManager : OsuTestScene - { - private static readonly string exe_path = Path.ChangeExtension(typeof(TestSceneWindowsAssociationManager).Assembly.Location, ".exe"); - - [Resolved] - private GameHost host { get; set; } = null!; - - private readonly WindowsAssociationManager associationManager; - - public TestSceneWindowsAssociationManager() - { - Children = new Drawable[] - { - new OsuSpriteText { Text = Environment.CommandLine }, - associationManager = new WindowsAssociationManager(exe_path, "osu.Test"), - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (Environment.CommandLine.Contains(".osz", StringComparison.Ordinal)) - ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkOliveGreen)); - - if (Environment.CommandLine.Contains("osu://", StringComparison.Ordinal)) - ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkBlue)); - - if (Environment.CommandLine.Contains("osump://", StringComparison.Ordinal)) - ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkRed)); - } - - [Test] - public void TestInstall() - { - AddStep("install", () => associationManager.InstallAssociations()); - } - - [Test] - public void TestOpenBeatmap() - { - string beatmapPath = null!; - AddStep("create temp beatmap", () => beatmapPath = TestResources.GetTestBeatmapForImport()); - AddAssert("beatmap path ends with .osz", () => beatmapPath, () => Does.EndWith(".osz")); - AddStep("open beatmap", () => host.OpenFileExternally(beatmapPath)); - AddUntilStep("wait for focus", () => host.IsActive.Value); - AddStep("delete temp beatmap", () => File.Delete(beatmapPath)); - } - - /// - /// To check that the icon is correct - /// - [Test] - public void TestPresentBeatmap() - { - string beatmapPath = null!; - AddStep("create temp beatmap", () => beatmapPath = TestResources.GetTestBeatmapForImport()); - AddAssert("beatmap path ends with .osz", () => beatmapPath, () => Does.EndWith(".osz")); - AddStep("show beatmap in explorer", () => host.PresentFileExternally(beatmapPath)); - AddUntilStep("wait for focus", () => host.IsActive.Value); - AddStep("delete temp beatmap", () => File.Delete(beatmapPath)); - } - - [TestCase("osu://s/1")] - [TestCase("osump://123")] - public void TestUrl(string url) - { - AddStep($"open {url}", () => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true })); - } - - [Test] - public void TestUninstall() - { - AddStep("uninstall", () => associationManager.UninstallAssociations()); - } - - /// - /// Useful when testing things out and manually changing the registry. - /// - [Test] - public void TestNotifyShell() - { - AddStep("notify shell of changes", WindowsAssociationManager.NotifyShellUpdate); - } - } -} From 17033e09f679f1f2c7800bdb552545967c42fa18 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 5 Feb 2024 14:29:17 +0100 Subject: [PATCH 10/58] Change to class to satisfy CFS hopefully --- osu.Desktop/Windows/Win32Icon.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Desktop/Windows/Win32Icon.cs b/osu.Desktop/Windows/Win32Icon.cs index 9544846c55..401e7a2be3 100644 --- a/osu.Desktop/Windows/Win32Icon.cs +++ b/osu.Desktop/Windows/Win32Icon.cs @@ -3,7 +3,7 @@ namespace osu.Desktop.Windows { - public record Win32Icon + public class Win32Icon { public readonly string Path; From 57d5717e6a6d4fb4007e6f42aa7bca525ea176e6 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 21:33:23 +0100 Subject: [PATCH 11/58] Remove `Win32Icon` class and use plain strings instead --- osu.Desktop/Windows/Icons.cs | 9 ++++++++- osu.Desktop/Windows/Win32Icon.cs | 16 ---------------- osu.Desktop/Windows/WindowsAssociationManager.cs | 8 ++++---- 3 files changed, 12 insertions(+), 21 deletions(-) delete mode 100644 osu.Desktop/Windows/Win32Icon.cs diff --git a/osu.Desktop/Windows/Icons.cs b/osu.Desktop/Windows/Icons.cs index cc60f92810..67915c101a 100644 --- a/osu.Desktop/Windows/Icons.cs +++ b/osu.Desktop/Windows/Icons.cs @@ -1,10 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.IO; + namespace osu.Desktop.Windows { public static class Icons { - public static Win32Icon Lazer => new Win32Icon(@"lazer.ico"); + /// + /// Fully qualified path to the directory that contains icons (in the installation folder). + /// + private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!; + + public static string Lazer => Path.Join(icon_directory, "lazer.ico"); } } diff --git a/osu.Desktop/Windows/Win32Icon.cs b/osu.Desktop/Windows/Win32Icon.cs deleted file mode 100644 index 401e7a2be3..0000000000 --- a/osu.Desktop/Windows/Win32Icon.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Desktop.Windows -{ - public class Win32Icon - { - public readonly string Path; - - internal Win32Icon(string name) - { - string dir = System.IO.Path.GetDirectoryName(typeof(Win32Icon).Assembly.Location)!; - Path = System.IO.Path.Join(dir, name); - } - } -} diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 7131067224..a93161ae47 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -173,7 +173,7 @@ namespace osu.Desktop.Windows #endregion - private record FileAssociation(string Extension, LocalisableString Description, Win32Icon Icon) + private record FileAssociation(string Extension, LocalisableString Description, string IconPath) { private string getProgramId(string prefix) => $@"{prefix}.File{Extension}"; @@ -188,7 +188,7 @@ namespace osu.Desktop.Windows using (var programKey = classes.CreateSubKey(programId)) { using (var defaultIconKey = programKey.CreateSubKey(DEFAULT_ICON)) - defaultIconKey.SetValue(null, Icon.Path); + defaultIconKey.SetValue(null, IconPath); using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); @@ -225,7 +225,7 @@ namespace osu.Desktop.Windows } } - private record UriAssociation(string Protocol, LocalisableString Description, Win32Icon Icon) + private record UriAssociation(string Protocol, LocalisableString Description, string IconPath) { /// /// "The URL Protocol string value indicates that this key declares a custom pluggable protocol handler." @@ -243,7 +243,7 @@ namespace osu.Desktop.Windows protocolKey.SetValue(URL_PROTOCOL, string.Empty); using (var defaultIconKey = protocolKey.CreateSubKey(DEFAULT_ICON)) - defaultIconKey.SetValue(null, Icon.Path); + defaultIconKey.SetValue(null, IconPath); using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); From eeba93768641b05a470aa1e8ebe18389596f5d49 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 21:45:36 +0100 Subject: [PATCH 12/58] Make `WindowsAssociationManager` `static` Usages/localisation logic TBD --- osu.Desktop/Program.cs | 2 +- .../Windows/WindowsAssociationManager.cs | 57 ++++++------------- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index edbf39a30a..38e4110f62 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -181,7 +181,7 @@ namespace osu.Desktop { tools.RemoveShortcutForThisExe(); tools.RemoveUninstallerRegistryEntry(); - WindowsAssociationManager.UninstallAssociations(@"osu"); + WindowsAssociationManager.UninstallAssociations(); }, onEveryRun: (_, _, _) => { // While setting the `ProcessAppUserModelId` fixes duplicate icons/shortcuts on the taskbar, it currently diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index a93161ae47..f1fc98090f 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -6,9 +6,6 @@ using System.IO; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.Win32; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Localisation; @@ -16,7 +13,7 @@ using osu.Game.Localisation; namespace osu.Desktop.Windows { [SupportedOSPlatform("windows")] - public partial class WindowsAssociationManager : Component + public static class WindowsAssociationManager { public const string SOFTWARE_CLASSES = @"Software\Classes"; @@ -54,25 +51,7 @@ namespace osu.Desktop.Windows new UriAssociation(@"osump", WindowsAssociationManagerStrings.OsuMultiplayer, Icons.Lazer), }; - [Resolved] - private LocalisationManager localisation { get; set; } = null!; - - private IBindable localisationParameters = null!; - - [BackgroundDependencyLoader] - private void load() - { - localisationParameters = localisation.CurrentParameters.GetBoundCopy(); - InstallAssociations(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - localisationParameters.ValueChanged += _ => updateDescriptions(); - } - - public void InstallAssociations() + public static void InstallAssociations(LocalisationManager? localisation) { try { @@ -88,7 +67,7 @@ namespace osu.Desktop.Windows association.Install(classes, EXE_PATH); } - updateDescriptions(); + updateDescriptions(localisation); } catch (Exception e) { @@ -96,7 +75,7 @@ namespace osu.Desktop.Windows } } - private void updateDescriptions() + private static void updateDescriptions(LocalisationManager? localisation) { try { @@ -105,18 +84,10 @@ namespace osu.Desktop.Windows return; foreach (var association in file_associations) - { - var b = localisation.GetLocalisedBindableString(association.Description); - association.UpdateDescription(classes, PROGRAM_ID_PREFIX, b.Value); - b.UnbindAll(); - } + association.UpdateDescription(classes, PROGRAM_ID_PREFIX, getLocalisedString(association.Description)); foreach (var association in uri_associations) - { - var b = localisation.GetLocalisedBindableString(association.Description); - association.UpdateDescription(classes, b.Value); - b.UnbindAll(); - } + association.UpdateDescription(classes, getLocalisedString(association.Description)); NotifyShellUpdate(); } @@ -124,11 +95,19 @@ namespace osu.Desktop.Windows { Logger.Log($@"Failed to update file and URI associations: {e.Message}"); } + + string getLocalisedString(LocalisableString s) + { + if (localisation == null) + return s.ToString(); + + var b = localisation.GetLocalisedBindableString(s); + b.UnbindAll(); + return b.Value; + } } - public void UninstallAssociations() => UninstallAssociations(PROGRAM_ID_PREFIX); - - public static void UninstallAssociations(string programIdPrefix) + public static void UninstallAssociations() { try { @@ -137,7 +116,7 @@ namespace osu.Desktop.Windows return; foreach (var association in file_associations) - association.Uninstall(classes, programIdPrefix); + association.Uninstall(classes, PROGRAM_ID_PREFIX); foreach (var association in uri_associations) association.Uninstall(classes); From f9d257b99ea176222671aa4594ec78c01f157db7 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 21:56:39 +0100 Subject: [PATCH 13/58] Install associations as part of initial squirrel install --- osu.Desktop/OsuGameDesktop.cs | 3 --- osu.Desktop/Program.cs | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 2e1b34fb38..a0db896f46 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -137,9 +137,6 @@ namespace osu.Desktop if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) LoadComponentAsync(new GameplayWinKeyBlocker(), Add); - if (OperatingSystem.IsWindows() && IsDeployedBuild) - LoadComponentAsync(new WindowsAssociationManager(), Add); - LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 38e4110f62..65236940c6 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -174,6 +174,7 @@ namespace osu.Desktop { tools.CreateShortcutForThisExe(); tools.CreateUninstallerRegistryEntry(); + WindowsAssociationManager.InstallAssociations(null); }, onAppUpdate: (_, tools) => { tools.CreateUninstallerRegistryEntry(); From 0563507295dcf68c150cfc99949964d521f98b0e Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:03:16 +0100 Subject: [PATCH 14/58] Remove duplicate try-catch and move `NotifyShellUpdate()` to less hidden place --- .../Windows/WindowsAssociationManager.cs | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index f1fc98090f..c91ab459d6 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -68,6 +68,7 @@ namespace osu.Desktop.Windows } updateDescriptions(localisation); + NotifyShellUpdate(); } catch (Exception e) { @@ -77,24 +78,15 @@ namespace osu.Desktop.Windows private static void updateDescriptions(LocalisationManager? localisation) { - try - { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - if (classes == null) - return; + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; - foreach (var association in file_associations) - association.UpdateDescription(classes, PROGRAM_ID_PREFIX, getLocalisedString(association.Description)); + foreach (var association in file_associations) + association.UpdateDescription(classes, PROGRAM_ID_PREFIX, getLocalisedString(association.Description)); - foreach (var association in uri_associations) - association.UpdateDescription(classes, getLocalisedString(association.Description)); - - NotifyShellUpdate(); - } - catch (Exception e) - { - Logger.Log($@"Failed to update file and URI associations: {e.Message}"); - } + foreach (var association in uri_associations) + association.UpdateDescription(classes, getLocalisedString(association.Description)); string getLocalisedString(LocalisableString s) { From 6bdb07602794d7eb59e7356f9fa5a04188652ff2 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:06:09 +0100 Subject: [PATCH 15/58] Move update/install logic into helper --- .../Windows/WindowsAssociationManager.cs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index c91ab459d6..3d61ad534b 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -55,18 +55,7 @@ namespace osu.Desktop.Windows { try { - using (var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, writable: true)) - { - if (classes == null) - return; - - foreach (var association in file_associations) - association.Install(classes, EXE_PATH, PROGRAM_ID_PREFIX); - - foreach (var association in uri_associations) - association.Install(classes, EXE_PATH); - } - + updateAssociations(); updateDescriptions(localisation); NotifyShellUpdate(); } @@ -76,6 +65,24 @@ namespace osu.Desktop.Windows } } + /// + /// Installs or updates associations. + /// + private static void updateAssociations() + { + using (var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, writable: true)) + { + if (classes == null) + return; + + foreach (var association in file_associations) + association.Install(classes, EXE_PATH, PROGRAM_ID_PREFIX); + + foreach (var association in uri_associations) + association.Install(classes, EXE_PATH); + } + } + private static void updateDescriptions(LocalisationManager? localisation) { using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); From 738c28755c53fd587d7ecee0cc6e8365c6aa8a44 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:17:13 +0100 Subject: [PATCH 16/58] Refactor public methods --- osu.Desktop/Program.cs | 3 +- .../Windows/WindowsAssociationManager.cs | 42 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 65236940c6..494d0df3c6 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -174,10 +174,11 @@ namespace osu.Desktop { tools.CreateShortcutForThisExe(); tools.CreateUninstallerRegistryEntry(); - WindowsAssociationManager.InstallAssociations(null); + WindowsAssociationManager.InstallAssociations(); }, onAppUpdate: (_, tools) => { tools.CreateUninstallerRegistryEntry(); + WindowsAssociationManager.UpdateAssociations(); }, onAppUninstall: (_, tools) => { tools.RemoveShortcutForThisExe(); diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 3d61ad534b..18a3c2da3d 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -51,12 +51,18 @@ namespace osu.Desktop.Windows new UriAssociation(@"osump", WindowsAssociationManagerStrings.OsuMultiplayer, Icons.Lazer), }; - public static void InstallAssociations(LocalisationManager? localisation) + /// + /// Installs file and URI associations. + /// + /// + /// Call in a timely fashion to keep descriptions up-to-date and localised. + /// + public static void InstallAssociations() { try { updateAssociations(); - updateDescriptions(localisation); + updateDescriptions(null); // write default descriptions in case `UpdateDescriptions()` is not called. NotifyShellUpdate(); } catch (Exception e) @@ -65,6 +71,38 @@ namespace osu.Desktop.Windows } } + /// + /// Updates associations with latest definitions. + /// + /// + /// Call in a timely fashion to keep descriptions up-to-date and localised. + /// + public static void UpdateAssociations() + { + try + { + updateAssociations(); + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log(@$"Failed to update file and URI associations: {e.Message}"); + } + } + + public static void UpdateDescriptions(LocalisationManager localisationManager) + { + try + { + updateDescriptions(localisationManager); + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log(@$"Failed to update file and URI association descriptions: {e.Message}"); + } + } + /// /// Installs or updates associations. /// From ffdefbc742fa1948d1e9356b438adbf319221bd3 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:18:12 +0100 Subject: [PATCH 17/58] Move public methods closer together --- .../Windows/WindowsAssociationManager.cs | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 18a3c2da3d..b7465e5ffc 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -103,6 +103,28 @@ namespace osu.Desktop.Windows } } + public static void UninstallAssociations() + { + try + { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; + + foreach (var association in file_associations) + association.Uninstall(classes, PROGRAM_ID_PREFIX); + + foreach (var association in uri_associations) + association.Uninstall(classes); + + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log($@"Failed to uninstall file and URI associations: {e.Message}"); + } + } + /// /// Installs or updates associations. /// @@ -144,28 +166,6 @@ namespace osu.Desktop.Windows } } - public static void UninstallAssociations() - { - try - { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - if (classes == null) - return; - - foreach (var association in file_associations) - association.Uninstall(classes, PROGRAM_ID_PREFIX); - - foreach (var association in uri_associations) - association.Uninstall(classes); - - NotifyShellUpdate(); - } - catch (Exception e) - { - Logger.Log($@"Failed to uninstall file and URI associations: {e.Message}"); - } - } - internal static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); #region Native interop From da8c4541dbfe8c8c0690ca57d91b6d428e94870d Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:21:04 +0100 Subject: [PATCH 18/58] Use `Logger.Error` --- osu.Desktop/Windows/WindowsAssociationManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index b7465e5ffc..c978e46b5b 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -67,7 +67,7 @@ namespace osu.Desktop.Windows } catch (Exception e) { - Logger.Log(@$"Failed to install file and URI associations: {e.Message}"); + Logger.Error(e, @$"Failed to install file and URI associations: {e.Message}"); } } @@ -86,7 +86,7 @@ namespace osu.Desktop.Windows } catch (Exception e) { - Logger.Log(@$"Failed to update file and URI associations: {e.Message}"); + Logger.Error(e, @"Failed to update file and URI associations."); } } @@ -99,7 +99,7 @@ namespace osu.Desktop.Windows } catch (Exception e) { - Logger.Log(@$"Failed to update file and URI association descriptions: {e.Message}"); + Logger.Error(e, @"Failed to update file and URI association descriptions."); } } @@ -121,7 +121,7 @@ namespace osu.Desktop.Windows } catch (Exception e) { - Logger.Log($@"Failed to uninstall file and URI associations: {e.Message}"); + Logger.Error(e, @"Failed to uninstall file and URI associations."); } } From 7f5dedc118059189e0d2bcb77e65ed39444de765 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:23:59 +0100 Subject: [PATCH 19/58] Refactor ProgID logic so it's more visible --- osu.Desktop/Windows/WindowsAssociationManager.cs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index c978e46b5b..aeda1e6283 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -35,7 +35,7 @@ namespace osu.Desktop.Windows /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. /// - public const string PROGRAM_ID_PREFIX = "osu"; + public const string PROGRAM_ID_PREFIX = "osu.File"; private static readonly FileAssociation[] file_associations = { @@ -136,7 +136,7 @@ namespace osu.Desktop.Windows return; foreach (var association in file_associations) - association.Install(classes, EXE_PATH, PROGRAM_ID_PREFIX); + association.Install(classes, EXE_PATH); foreach (var association in uri_associations) association.Install(classes, EXE_PATH); @@ -191,15 +191,13 @@ namespace osu.Desktop.Windows private record FileAssociation(string Extension, LocalisableString Description, string IconPath) { - private string getProgramId(string prefix) => $@"{prefix}.File{Extension}"; + private string programId => $@"{PROGRAM_ID_PREFIX}{Extension}"; /// /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key /// - public void Install(RegistryKey classes, string exePath, string programIdPrefix) + public void Install(RegistryKey classes, string exePath) { - string programId = getProgramId(programIdPrefix); - // register a program id for the given extension using (var programKey = classes.CreateSubKey(programId)) { @@ -224,14 +222,12 @@ namespace osu.Desktop.Windows public void UpdateDescription(RegistryKey classes, string programIdPrefix, string description) { - using (var programKey = classes.OpenSubKey(getProgramId(programIdPrefix), true)) + using (var programKey = classes.OpenSubKey(programId, true)) programKey?.SetValue(null, description); } public void Uninstall(RegistryKey classes, string programIdPrefix) { - string programId = getProgramId(programIdPrefix); - // importantly, we don't delete the default program entry because some other program could have taken it. using (var extensionKey = classes.OpenSubKey($@"{Extension}\OpenWithProgIds", true)) From 3419b8ffa854b4b40daf26fe248e89a4e812e84a Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:26:21 +0100 Subject: [PATCH 20/58] Standardise using declaration --- osu.Desktop/Windows/WindowsAssociationManager.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index aeda1e6283..a786afde55 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -130,17 +130,15 @@ namespace osu.Desktop.Windows /// private static void updateAssociations() { - using (var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, writable: true)) - { - if (classes == null) - return; + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; - foreach (var association in file_associations) - association.Install(classes, EXE_PATH); + foreach (var association in file_associations) + association.Install(classes, EXE_PATH); - foreach (var association in uri_associations) - association.Install(classes, EXE_PATH); - } + foreach (var association in uri_associations) + association.Install(classes, EXE_PATH); } private static void updateDescriptions(LocalisationManager? localisation) From 139072fa818a088f300fce9cfd38682c9c57dbcd Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:29:20 +0100 Subject: [PATCH 21/58] Standardise using declaration --- osu.Desktop/Windows/WindowsAssociationManager.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index a786afde55..2373cfa609 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.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.Runtime.InteropServices; using System.Runtime.Versioning; @@ -108,8 +109,7 @@ namespace osu.Desktop.Windows try { using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - if (classes == null) - return; + Debug.Assert(classes != null); foreach (var association in file_associations) association.Uninstall(classes, PROGRAM_ID_PREFIX); @@ -131,8 +131,7 @@ namespace osu.Desktop.Windows private static void updateAssociations() { using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - if (classes == null) - return; + Debug.Assert(classes != null); foreach (var association in file_associations) association.Install(classes, EXE_PATH); @@ -144,8 +143,7 @@ namespace osu.Desktop.Windows private static void updateDescriptions(LocalisationManager? localisation) { using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - if (classes == null) - return; + Debug.Assert(classes != null); foreach (var association in file_associations) association.UpdateDescription(classes, PROGRAM_ID_PREFIX, getLocalisedString(association.Description)); From bf47221594a805530cee18ab5e2ff802bd54f232 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:42:42 +0100 Subject: [PATCH 22/58] Make things testable via 'Run static method' in Rider --- osu.Desktop/Windows/WindowsAssociationManager.cs | 2 +- osu.Desktop/osu.Desktop.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 2373cfa609..83fadfcae2 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -30,7 +30,7 @@ namespace osu.Desktop.Windows /// public const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; - public static readonly string EXE_PATH = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe"); + public static readonly string EXE_PATH = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe").Replace('/', '\\'); /// /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index c6a95c1623..57752aa1f7 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -31,7 +31,7 @@ - + From 8049270ad2e17447a5f80446a7e39e73dc5e52e2 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Wed, 7 Feb 2024 22:45:58 +0100 Subject: [PATCH 23/58] Remove unused param --- osu.Desktop/Windows/WindowsAssociationManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 83fadfcae2..83c2a97b56 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -112,7 +112,7 @@ namespace osu.Desktop.Windows Debug.Assert(classes != null); foreach (var association in file_associations) - association.Uninstall(classes, PROGRAM_ID_PREFIX); + association.Uninstall(classes); foreach (var association in uri_associations) association.Uninstall(classes); @@ -146,7 +146,7 @@ namespace osu.Desktop.Windows Debug.Assert(classes != null); foreach (var association in file_associations) - association.UpdateDescription(classes, PROGRAM_ID_PREFIX, getLocalisedString(association.Description)); + association.UpdateDescription(classes, getLocalisedString(association.Description)); foreach (var association in uri_associations) association.UpdateDescription(classes, getLocalisedString(association.Description)); @@ -216,13 +216,13 @@ namespace osu.Desktop.Windows } } - public void UpdateDescription(RegistryKey classes, string programIdPrefix, string description) + public void UpdateDescription(RegistryKey classes, string description) { using (var programKey = classes.OpenSubKey(programId, true)) programKey?.SetValue(null, description); } - public void Uninstall(RegistryKey classes, string programIdPrefix) + public void Uninstall(RegistryKey classes) { // importantly, we don't delete the default program entry because some other program could have taken it. From dfa0c51bc8b1067a08811f221da52109a6806c94 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Thu, 8 Feb 2024 00:23:46 +0100 Subject: [PATCH 24/58] Copy icons to nuget and install package Don't ask me why this uses the folder for .NET Framework 4.5 --- osu.Desktop/osu.nuspec | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index f85698680e..66b3970351 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -20,6 +20,7 @@ + From 1dc54d6f25d030db88936ecbfec7dd8f55c71d3e Mon Sep 17 00:00:00 2001 From: Susko3 Date: Thu, 8 Feb 2024 00:54:48 +0100 Subject: [PATCH 25/58] Fix stable install path lookup `osu` is the `osu://` protocol handler, which gets overriden by lazer. Instead, use `osu!` which is the stable file handler. --- osu.Desktop/OsuGameDesktop.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index a0db896f46..5ac6ac7322 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -86,8 +86,8 @@ namespace osu.Desktop [SupportedOSPlatform("windows")] private string? getStableInstallPathFromRegistry() { - using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu")) - return key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); + using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu!")) + return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); } protected override UpdateManager CreateUpdateManager() From 6ded79cf0728010aedfb367cf48f3fd3f84abe6c Mon Sep 17 00:00:00 2001 From: Susko3 Date: Thu, 8 Feb 2024 01:15:37 +0100 Subject: [PATCH 26/58] Make `NotifyShellUpdate()` `public` to ease testing --- osu.Desktop/Windows/WindowsAssociationManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 83c2a97b56..4bb8e57c9d 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -125,6 +125,8 @@ namespace osu.Desktop.Windows } } + public static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + /// /// Installs or updates associations. /// @@ -162,8 +164,6 @@ namespace osu.Desktop.Windows } } - internal static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); - #region Native interop [DllImport("Shell32.dll")] From 1eb04c5190220e97b901ab989985709f1553fa22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Feb 2024 11:24:31 +0100 Subject: [PATCH 27/58] Add failing test coverage for catch --- .../CatchHealthProcessorTest.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs diff --git a/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs new file mode 100644 index 0000000000..8c2d1a91ab --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Judgements; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Scoring; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class CatchHealthProcessorTest + { + private static readonly object[][] test_cases = + [ + // hitobject, starting HP, fail expected after miss + [new Fruit(), 0.01, true], + [new Droplet(), 0.01, true], + [new TinyDroplet(), 0, true], + [new Banana(), 0, false], + ]; + + [TestCaseSource(nameof(test_cases))] + public void TestFailAfterMinResult(CatchHitObject hitObject, double startingHealth, bool failExpected) + { + var healthProcessor = new CatchHealthProcessor(0); + healthProcessor.ApplyBeatmap(new CatchBeatmap + { + HitObjects = { hitObject } + }); + healthProcessor.Health.Value = startingHealth; + + var result = new CatchJudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MinResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.EqualTo(failExpected)); + } + + [TestCaseSource(nameof(test_cases))] + public void TestNoFailAfterMaxResult(CatchHitObject hitObject, double startingHealth, bool _) + { + var healthProcessor = new CatchHealthProcessor(0); + healthProcessor.ApplyBeatmap(new CatchBeatmap + { + HitObjects = { hitObject } + }); + healthProcessor.Health.Value = startingHealth; + + var result = new CatchJudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MaxResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.False); + } + } +} From 86801aa51d9716228f24fb4728607f95f2ed8f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Feb 2024 11:57:42 +0100 Subject: [PATCH 28/58] Add failing test coverage for osu! --- .../OsuHealthProcessorTest.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 osu.Game.Rulesets.Osu.Tests/OsuHealthProcessorTest.cs diff --git a/osu.Game.Rulesets.Osu.Tests/OsuHealthProcessorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuHealthProcessorTest.cs new file mode 100644 index 0000000000..cf93e0ce7b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/OsuHealthProcessorTest.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 NUnit.Framework; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Scoring; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class OsuHealthProcessorTest + { + private static readonly object[][] test_cases = + [ + // hitobject, starting HP, fail expected after miss + [new HitCircle(), 0.01, true], + [new SliderHeadCircle(), 0.01, true], + [new SliderHeadCircle { ClassicSliderBehaviour = true }, 0.01, true], + [new SliderTick(), 0.01, true], + [new SliderRepeat(new Slider()), 0.01, true], + [new SliderTailCircle(new Slider()), 0, true], + [new SliderTailCircle(new Slider()) { ClassicSliderBehaviour = true }, 0.01, true], + [new Slider(), 0, true], + [new Slider { ClassicSliderBehaviour = true }, 0.01, true], + [new SpinnerTick(), 0, false], + [new SpinnerBonusTick(), 0, false], + [new Spinner(), 0.01, true], + ]; + + [TestCaseSource(nameof(test_cases))] + public void TestFailAfterMinResult(OsuHitObject hitObject, double startingHealth, bool failExpected) + { + var healthProcessor = new OsuHealthProcessor(0); + healthProcessor.ApplyBeatmap(new OsuBeatmap + { + HitObjects = { hitObject } + }); + healthProcessor.Health.Value = startingHealth; + + var result = new OsuJudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MinResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.EqualTo(failExpected)); + } + + [TestCaseSource(nameof(test_cases))] + public void TestNoFailAfterMaxResult(OsuHitObject hitObject, double startingHealth, bool _) + { + var healthProcessor = new OsuHealthProcessor(0); + healthProcessor.ApplyBeatmap(new OsuBeatmap + { + HitObjects = { hitObject } + }); + healthProcessor.Health.Value = startingHealth; + + var result = new OsuJudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MaxResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.False); + } + } +} From 441a7b3c2ff68fc13fefb439698e4605f9adacc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Feb 2024 12:07:40 +0100 Subject: [PATCH 29/58] Add precautionary taiko test coverage --- .../TaikoHealthProcessorTest.cs | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoHealthProcessorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoHealthProcessorTest.cs index f4a1e888c9..aba967cdd6 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoHealthProcessorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoHealthProcessorTest.cs @@ -147,7 +147,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { HitObjects = { - new DrumRoll { Duration = 2000 } + new Swell { Duration = 2000 } } }; @@ -172,5 +172,85 @@ namespace osu.Game.Rulesets.Taiko.Tests Assert.That(healthProcessor.HasFailed, Is.False); }); } + + [Test] + public void TestMissHitAndHitSwell() + { + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit(), + new Swell { Duration = 2000 } + } + }; + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var healthProcessor = new TaikoHealthProcessor(); + healthProcessor.ApplyBeatmap(beatmap); + + healthProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new TaikoJudgement()) { Type = HitResult.Miss }); + + foreach (var nested in beatmap.HitObjects[1].NestedHitObjects) + { + var nestedJudgement = nested.CreateJudgement(); + healthProcessor.ApplyResult(new JudgementResult(nested, nestedJudgement) { Type = nestedJudgement.MaxResult }); + } + + var judgement = beatmap.HitObjects[1].CreateJudgement(); + healthProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[1], judgement) { Type = judgement.MaxResult }); + + Assert.Multiple(() => + { + Assert.That(healthProcessor.Health.Value, Is.EqualTo(0)); + Assert.That(healthProcessor.HasFailed, Is.True); + }); + } + + private static readonly object[][] test_cases = + [ + // hitobject, fail expected after miss + [new Hit(), true], + [new Hit.StrongNestedHit(new Hit()), false], + [new DrumRollTick(new DrumRoll()), false], + [new DrumRollTick.StrongNestedHit(new DrumRollTick(new DrumRoll())), false], + [new DrumRoll(), false], + [new SwellTick(), false], + [new Swell(), false] + ]; + + [TestCaseSource(nameof(test_cases))] + public void TestFailAfterMinResult(TaikoHitObject hitObject, bool failExpected) + { + var healthProcessor = new TaikoHealthProcessor(); + healthProcessor.ApplyBeatmap(new TaikoBeatmap + { + HitObjects = { hitObject } + }); + + var result = new JudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MinResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.EqualTo(failExpected)); + } + + [TestCaseSource(nameof(test_cases))] + public void TestNoFailAfterMaxResult(TaikoHitObject hitObject, bool _) + { + var healthProcessor = new TaikoHealthProcessor(); + healthProcessor.ApplyBeatmap(new TaikoBeatmap + { + HitObjects = { hitObject } + }); + + var result = new JudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MaxResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.False); + } } } From d07ea8f5b12886883a60cf9c477fa9d632404ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Feb 2024 12:17:38 +0100 Subject: [PATCH 30/58] Add failing test coverage for mania --- .../ManiaHealthProcessorTest.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs index 315849f7de..a9771a46f3 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Scoring; @@ -27,5 +28,49 @@ namespace osu.Game.Rulesets.Mania.Tests // No matter what, mania doesn't have passive HP drain. Assert.That(processor.DrainRate, Is.Zero); } + + private static readonly object[][] test_cases = + [ + // hitobject, starting HP, fail expected after miss + [new Note(), 0.01, true], + [new HeadNote(), 0.01, true], + [new TailNote(), 0.01, true], + [new HoldNoteBody(), 0, true], // hold note break + [new HoldNote(), 0, true], + ]; + + [TestCaseSource(nameof(test_cases))] + public void TestFailAfterMinResult(ManiaHitObject hitObject, double startingHealth, bool failExpected) + { + var healthProcessor = new ManiaHealthProcessor(0); + healthProcessor.ApplyBeatmap(new ManiaBeatmap(new StageDefinition(4)) + { + HitObjects = { hitObject } + }); + healthProcessor.Health.Value = startingHealth; + + var result = new JudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MinResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.EqualTo(failExpected)); + } + + [TestCaseSource(nameof(test_cases))] + public void TestNoFailAfterMaxResult(ManiaHitObject hitObject, double startingHealth, bool _) + { + var healthProcessor = new ManiaHealthProcessor(0); + healthProcessor.ApplyBeatmap(new ManiaBeatmap(new StageDefinition(4)) + { + HitObjects = { hitObject } + }); + healthProcessor.Health.Value = startingHealth; + + var result = new JudgementResult(hitObject, hitObject.CreateJudgement()); + result.Type = result.Judgement.MaxResult; + healthProcessor.ApplyResult(result); + + Assert.That(healthProcessor.HasFailed, Is.False); + } } } From 16d893d40c10542a2502884df95d54773044ffb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Feb 2024 12:26:37 +0100 Subject: [PATCH 31/58] Fix draining processor failing gameplay on bonus misses and ignore hits --- osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs | 5 +++++ osu.Game/Rulesets/Scoring/HealthProcessor.cs | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index 629a84ea62..92a064385b 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -142,6 +142,11 @@ namespace osu.Game.Rulesets.Scoring } } + protected override bool CanFailOn(JudgementResult result) + { + return !result.Judgement.MaxResult.IsBonus() && result.Type != HitResult.IgnoreHit; + } + protected override void Reset(bool storeResults) { base.Reset(storeResults); diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index b5eb755650..ccf53f075a 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Scoring Health.Value += GetHealthIncreaseFor(result); - if (meetsAnyFailCondition(result)) + if (CanFailOn(result) && meetsAnyFailCondition(result)) TriggerFailure(); } @@ -68,6 +68,13 @@ namespace osu.Game.Rulesets.Scoring /// The health increase. protected virtual double GetHealthIncreaseFor(JudgementResult result) => result.HealthIncrease; + /// + /// Whether a failure can occur on a given . + /// If the return value of this method is , neither nor will be checked + /// after this . + /// + protected virtual bool CanFailOn(JudgementResult result) => true; + /// /// The default conditions for failing. /// From 3d8d0f8430487c4c30e8964f11e52e1a6522f098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 14 Feb 2024 08:37:20 +0100 Subject: [PATCH 32/58] Update test expectations for catch --- osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs index 8c2d1a91ab..d0a8ce4bbc 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchHealthProcessorTest.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Catch.Tests // hitobject, starting HP, fail expected after miss [new Fruit(), 0.01, true], [new Droplet(), 0.01, true], - [new TinyDroplet(), 0, true], + [new TinyDroplet(), 0, false], [new Banana(), 0, false], ]; From f53bce8ff785a1c52bcd1bffb365e43287ec8bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 14 Feb 2024 08:38:39 +0100 Subject: [PATCH 33/58] Fix catch health processor allowing fail on tiny droplet Closes https://github.com/ppy/osu/issues/27159. In today's episode of "just stable things"... --- .../Scoring/CatchHealthProcessor.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs index c3cc488941..2e1aec0803 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -21,6 +22,19 @@ namespace osu.Game.Rulesets.Catch.Scoring protected override IEnumerable EnumerateNestedHitObjects(HitObject hitObject) => Enumerable.Empty(); + protected override bool CanFailOn(JudgementResult result) + { + // matches stable. + // see: https://github.com/peppy/osu-stable-reference/blob/46cd3a10af7cc6cc96f4eba92ef1812dc8c3a27e/osu!/GameModes/Play/Rulesets/Ruleset.cs#L967 + // the above early-return skips the failure check at the end of the same method: + // https://github.com/peppy/osu-stable-reference/blob/46cd3a10af7cc6cc96f4eba92ef1812dc8c3a27e/osu!/GameModes/Play/Rulesets/Ruleset.cs#L1232 + // making it impossible to fail on a tiny droplet regardless of result. + if (result.Type == HitResult.SmallTickMiss) + return false; + + return base.CanFailOn(result); + } + protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result) { double increase = 0; From 2c0a5b7ef54169412c805ad4b05ea47cf131c012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 14 Feb 2024 14:21:48 +0100 Subject: [PATCH 34/58] Fix missing tiny droplet not triggering fail with perfect on Stable does this: https://github.com/peppy/osu-stable-reference/blob/46cd3a10af7cc6cc96f4eba92ef1812dc8c3a27e/osu!/GameplayElements/HitObjectManagerFruits.cs#L98-L102 I'd rather not say what I think about it doing that, since it's likely to be unpublishable, but to approximate that, just make it so that only the "default fail condition" is beholden to the weird ebbs and flows of what the ruleset wants. This appears to fix the problem case and I'm hoping it doesn't break something else but I'm like 50/50 on it happening anyway at this point. Just gotta add tests add nauseam. --- .../Scoring/CatchHealthProcessor.cs | 4 ++-- .../Scoring/AccumulatingHealthProcessor.cs | 4 +++- .../Scoring/DrainingHealthProcessor.cs | 7 +++++-- osu.Game/Rulesets/Scoring/HealthProcessor.cs | 18 ++++++------------ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs index 2e1aec0803..2f55f9a85f 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Scoring protected override IEnumerable EnumerateNestedHitObjects(HitObject hitObject) => Enumerable.Empty(); - protected override bool CanFailOn(JudgementResult result) + protected override bool CheckDefaultFailCondition(JudgementResult result) { // matches stable. // see: https://github.com/peppy/osu-stable-reference/blob/46cd3a10af7cc6cc96f4eba92ef1812dc8c3a27e/osu!/GameModes/Play/Rulesets/Ruleset.cs#L967 @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Scoring if (result.Type == HitResult.SmallTickMiss) return false; - return base.CanFailOn(result); + return base.CheckDefaultFailCondition(result); } protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result) diff --git a/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs index 422bf8ea79..bb4c2463a7 100644 --- a/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs @@ -1,6 +1,8 @@ // 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.Rulesets.Judgements; + namespace osu.Game.Rulesets.Scoring { /// @@ -9,7 +11,7 @@ namespace osu.Game.Rulesets.Scoring /// public partial class AccumulatingHealthProcessor : HealthProcessor { - protected override bool DefaultFailCondition => JudgedHits == MaxHits && Health.Value < requiredHealth; + protected override bool CheckDefaultFailCondition(JudgementResult _) => JudgedHits == MaxHits && Health.Value < requiredHealth; private readonly double requiredHealth; diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index 92a064385b..e72a8aaf67 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -142,9 +142,12 @@ namespace osu.Game.Rulesets.Scoring } } - protected override bool CanFailOn(JudgementResult result) + protected override bool CheckDefaultFailCondition(JudgementResult result) { - return !result.Judgement.MaxResult.IsBonus() && result.Type != HitResult.IgnoreHit; + if (result.Judgement.MaxResult.IsBonus() || result.Type == HitResult.IgnoreHit) + return false; + + return base.CheckDefaultFailCondition(result); } protected override void Reset(bool storeResults) diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index ccf53f075a..9e4c06b783 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Scoring public event Func? Failed; /// - /// Additional conditions on top of that cause a failing state. + /// Additional conditions on top of that cause a failing state. /// public event Func? FailConditions; @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Scoring Health.Value += GetHealthIncreaseFor(result); - if (CanFailOn(result) && meetsAnyFailCondition(result)) + if (meetsAnyFailCondition(result)) TriggerFailure(); } @@ -69,16 +69,10 @@ namespace osu.Game.Rulesets.Scoring protected virtual double GetHealthIncreaseFor(JudgementResult result) => result.HealthIncrease; /// - /// Whether a failure can occur on a given . - /// If the return value of this method is , neither nor will be checked - /// after this . + /// Checks whether the default conditions for failing are met. /// - protected virtual bool CanFailOn(JudgementResult result) => true; - - /// - /// The default conditions for failing. - /// - protected virtual bool DefaultFailCondition => Precision.AlmostBigger(Health.MinValue, Health.Value); + /// if failure should be invoked. + protected virtual bool CheckDefaultFailCondition(JudgementResult result) => Precision.AlmostBigger(Health.MinValue, Health.Value); /// /// Whether the current state of or the provided meets any fail condition. @@ -86,7 +80,7 @@ namespace osu.Game.Rulesets.Scoring /// The judgement result. private bool meetsAnyFailCondition(JudgementResult result) { - if (DefaultFailCondition) + if (CheckDefaultFailCondition(result)) return true; if (FailConditions != null) From 6dbba705b3127757dbb36f16911790d776934527 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 26 Feb 2024 12:27:02 +0100 Subject: [PATCH 35/58] Refine uninstall logic to account for legacy windows features --- osu.Desktop/Windows/WindowsAssociationManager.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 4bb8e57c9d..490faab632 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -222,12 +222,21 @@ namespace osu.Desktop.Windows programKey?.SetValue(null, description); } + /// + /// Uninstalls the file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#deleting-registry-information-during-uninstallation + /// public void Uninstall(RegistryKey classes) { - // importantly, we don't delete the default program entry because some other program could have taken it. + using (var extensionKey = classes.OpenSubKey(Extension, true)) + { + // clear our default association so that Explorer doesn't show the raw programId to users + // the null/(Default) entry is used for both ProdID association and as a fallback friendly name, for legacy reasons + if (extensionKey?.GetValue(null) is string s && s == programId) + extensionKey.SetValue(null, string.Empty); - using (var extensionKey = classes.OpenSubKey($@"{Extension}\OpenWithProgIds", true)) - extensionKey?.DeleteValue(programId, throwOnMissingValue: false); + using (var openWithKey = extensionKey?.CreateSubKey(@"OpenWithProgIds")) + openWithKey?.DeleteValue(programId, throwOnMissingValue: false); + } classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false); } From f2807470efc66a560dbd09da75be2ff70bf83b1d Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 26 Feb 2024 13:03:23 +0100 Subject: [PATCH 36/58] Inline `EXE_PATH` usage --- osu.Desktop/Windows/WindowsAssociationManager.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 490faab632..3fd566edab 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -136,10 +136,10 @@ namespace osu.Desktop.Windows Debug.Assert(classes != null); foreach (var association in file_associations) - association.Install(classes, EXE_PATH); + association.Install(classes); foreach (var association in uri_associations) - association.Install(classes, EXE_PATH); + association.Install(classes); } private static void updateDescriptions(LocalisationManager? localisation) @@ -192,7 +192,7 @@ namespace osu.Desktop.Windows /// /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key /// - public void Install(RegistryKey classes, string exePath) + public void Install(RegistryKey classes) { // register a program id for the given extension using (var programKey = classes.CreateSubKey(programId)) @@ -201,7 +201,7 @@ namespace osu.Desktop.Windows defaultIconKey.SetValue(null, IconPath); using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) - openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); + openCommandKey.SetValue(null, $@"""{EXE_PATH}"" ""%1"""); } using (var extensionKey = classes.CreateSubKey(Extension)) @@ -253,7 +253,7 @@ namespace osu.Desktop.Windows /// /// Registers an URI protocol handler in accordance with https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). /// - public void Install(RegistryKey classes, string exePath) + public void Install(RegistryKey classes) { using (var protocolKey = classes.CreateSubKey(Protocol)) { @@ -263,7 +263,7 @@ namespace osu.Desktop.Windows defaultIconKey.SetValue(null, IconPath); using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) - openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); + openCommandKey.SetValue(null, $@"""{EXE_PATH}"" ""%1"""); } } From 9b3ec64f411b234391ac653fb319cbb07d1d6fea Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 26 Feb 2024 13:10:37 +0100 Subject: [PATCH 37/58] Inline `HKCU\Software\Classes` usage --- .../Windows/WindowsAssociationManager.cs | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 3fd566edab..2a1aeba7e0 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Runtime.Versioning; @@ -108,14 +107,11 @@ namespace osu.Desktop.Windows { try { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - Debug.Assert(classes != null); - foreach (var association in file_associations) - association.Uninstall(classes); + association.Uninstall(); foreach (var association in uri_associations) - association.Uninstall(classes); + association.Uninstall(); NotifyShellUpdate(); } @@ -132,26 +128,20 @@ namespace osu.Desktop.Windows /// private static void updateAssociations() { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - Debug.Assert(classes != null); - foreach (var association in file_associations) - association.Install(classes); + association.Install(); foreach (var association in uri_associations) - association.Install(classes); + association.Install(); } private static void updateDescriptions(LocalisationManager? localisation) { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); - Debug.Assert(classes != null); - foreach (var association in file_associations) - association.UpdateDescription(classes, getLocalisedString(association.Description)); + association.UpdateDescription(getLocalisedString(association.Description)); foreach (var association in uri_associations) - association.UpdateDescription(classes, getLocalisedString(association.Description)); + association.UpdateDescription(getLocalisedString(association.Description)); string getLocalisedString(LocalisableString s) { @@ -192,8 +182,11 @@ namespace osu.Desktop.Windows /// /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key /// - public void Install(RegistryKey classes) + public void Install() { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) return; + // register a program id for the given extension using (var programKey = classes.CreateSubKey(programId)) { @@ -216,8 +209,11 @@ namespace osu.Desktop.Windows } } - public void UpdateDescription(RegistryKey classes, string description) + public void UpdateDescription(string description) { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) return; + using (var programKey = classes.OpenSubKey(programId, true)) programKey?.SetValue(null, description); } @@ -225,8 +221,11 @@ namespace osu.Desktop.Windows /// /// Uninstalls the file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#deleting-registry-information-during-uninstallation /// - public void Uninstall(RegistryKey classes) + public void Uninstall() { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) return; + using (var extensionKey = classes.OpenSubKey(Extension, true)) { // clear our default association so that Explorer doesn't show the raw programId to users @@ -253,8 +252,11 @@ namespace osu.Desktop.Windows /// /// Registers an URI protocol handler in accordance with https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). /// - public void Install(RegistryKey classes) + public void Install() { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) return; + using (var protocolKey = classes.CreateSubKey(Protocol)) { protocolKey.SetValue(URL_PROTOCOL, string.Empty); @@ -267,15 +269,19 @@ namespace osu.Desktop.Windows } } - public void UpdateDescription(RegistryKey classes, string description) + public void UpdateDescription(string description) { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) return; + using (var protocolKey = classes.OpenSubKey(Protocol, true)) protocolKey?.SetValue(null, $@"URL:{description}"); } - public void Uninstall(RegistryKey classes) + public void Uninstall() { - classes.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + classes?.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); } } } From 87509fbf6efc9e14979a97fdb14b5fc6b56bdf16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 27 Feb 2024 13:47:19 +0100 Subject: [PATCH 38/58] Privatise registry-related constants Don't see any reason for them to be public. --- .../Windows/WindowsAssociationManager.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 2a1aeba7e0..c784d52a4f 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -15,27 +15,27 @@ namespace osu.Desktop.Windows [SupportedOSPlatform("windows")] public static class WindowsAssociationManager { - public const string SOFTWARE_CLASSES = @"Software\Classes"; + private const string software_classes = @"Software\Classes"; /// /// Sub key for setting the icon. /// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon /// - public const string DEFAULT_ICON = @"DefaultIcon"; + private const string default_icon = @"DefaultIcon"; /// /// Sub key for setting the command line that the shell invokes. /// https://learn.microsoft.com/en-us/windows/win32/com/shell /// - public const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; + internal const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; - public static readonly string EXE_PATH = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe").Replace('/', '\\'); + private static readonly string exe_path = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe").Replace('/', '\\'); /// /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. /// - public const string PROGRAM_ID_PREFIX = "osu.File"; + private const string program_id_prefix = "osu.File"; private static readonly FileAssociation[] file_associations = { @@ -177,24 +177,24 @@ namespace osu.Desktop.Windows private record FileAssociation(string Extension, LocalisableString Description, string IconPath) { - private string programId => $@"{PROGRAM_ID_PREFIX}{Extension}"; + private string programId => $@"{program_id_prefix}{Extension}"; /// /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key /// public void Install() { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); if (classes == null) return; // register a program id for the given extension using (var programKey = classes.CreateSubKey(programId)) { - using (var defaultIconKey = programKey.CreateSubKey(DEFAULT_ICON)) + using (var defaultIconKey = programKey.CreateSubKey(default_icon)) defaultIconKey.SetValue(null, IconPath); using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) - openCommandKey.SetValue(null, $@"""{EXE_PATH}"" ""%1"""); + openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1"""); } using (var extensionKey = classes.CreateSubKey(Extension)) @@ -211,7 +211,7 @@ namespace osu.Desktop.Windows public void UpdateDescription(string description) { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); if (classes == null) return; using (var programKey = classes.OpenSubKey(programId, true)) @@ -223,7 +223,7 @@ namespace osu.Desktop.Windows /// public void Uninstall() { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); if (classes == null) return; using (var extensionKey = classes.OpenSubKey(Extension, true)) @@ -254,24 +254,24 @@ namespace osu.Desktop.Windows /// public void Install() { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); if (classes == null) return; using (var protocolKey = classes.CreateSubKey(Protocol)) { protocolKey.SetValue(URL_PROTOCOL, string.Empty); - using (var defaultIconKey = protocolKey.CreateSubKey(DEFAULT_ICON)) + using (var defaultIconKey = protocolKey.CreateSubKey(default_icon)) defaultIconKey.SetValue(null, IconPath); using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) - openCommandKey.SetValue(null, $@"""{EXE_PATH}"" ""%1"""); + openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1"""); } } public void UpdateDescription(string description) { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); if (classes == null) return; using (var protocolKey = classes.OpenSubKey(Protocol, true)) @@ -280,7 +280,7 @@ namespace osu.Desktop.Windows public void Uninstall() { - using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); classes?.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); } } From 7f5f3804f1695e130a2ab41d5e4fda092a32ed1d Mon Sep 17 00:00:00 2001 From: Salman Ahmed Date: Thu, 29 Feb 2024 05:39:36 +0300 Subject: [PATCH 39/58] Expose beatmap storyboard as part of `GameplayState` --- osu.Game/Screens/Play/GameplayState.cs | 9 ++++++++- osu.Game/Screens/Play/Player.cs | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index cc399a0fbe..8b0207a340 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Storyboards; namespace osu.Game.Screens.Play { @@ -40,6 +41,11 @@ namespace osu.Game.Screens.Play public readonly ScoreProcessor ScoreProcessor; + /// + /// The storyboard associated with the beatmap. + /// + public readonly Storyboard Storyboard; + /// /// Whether gameplay completed without the user failing. /// @@ -62,7 +68,7 @@ namespace osu.Game.Screens.Play private readonly Bindable lastJudgementResult = new Bindable(); - public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null, ScoreProcessor? scoreProcessor = null) + public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null, ScoreProcessor? scoreProcessor = null, Storyboard? storyboard = null) { Beatmap = beatmap; Ruleset = ruleset; @@ -76,6 +82,7 @@ namespace osu.Game.Screens.Play }; Mods = mods ?? Array.Empty(); ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor(); + Storyboard = storyboard ?? new Storyboard(); } /// diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 88f6ae9e71..10ada09be7 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -255,7 +255,7 @@ namespace osu.Game.Screens.Play Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; - dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor)); + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, Beatmap.Value.Storyboard)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); @@ -397,7 +397,7 @@ namespace osu.Game.Screens.Play protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); private Drawable createUnderlayComponents() => - DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }; + DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }; private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay) { @@ -456,7 +456,7 @@ namespace osu.Game.Screens.Play { RequestSkip = performUserRequestedSkip }, - skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0) + skipOutroOverlay = new SkipOverlay(GameplayState.Storyboard.LatestEventTime ?? 0) { RequestSkip = () => progressToResults(false), Alpha = 0 @@ -1088,7 +1088,7 @@ namespace osu.Game.Screens.Play DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); - storyboardReplacesBackground.Value = Beatmap.Value.Storyboard.ReplacesBackground && Beatmap.Value.Storyboard.HasDrawable; + storyboardReplacesBackground.Value = GameplayState.Storyboard.ReplacesBackground && GameplayState.Storyboard.HasDrawable; foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToPlayer(this); From 847a8ead4fbdd86149b10fad884ed664a94e5352 Mon Sep 17 00:00:00 2001 From: Salman Ahmed Date: Thu, 29 Feb 2024 05:39:59 +0300 Subject: [PATCH 40/58] Hide taiko scroller when beatmap has storyboard --- .../UI/DrawableTaikoRuleset.cs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 77b2b06c0e..ff969c3f74 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -22,7 +24,9 @@ using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osu.Game.Skinning; +using osu.Game.Storyboards; using osuTK; namespace osu.Game.Rulesets.Taiko.UI @@ -39,6 +43,7 @@ namespace osu.Game.Rulesets.Taiko.UI protected override bool UserScrollSpeedAdjustment => false; + [CanBeNull] private SkinnableDrawable scroller; public DrawableTaikoRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) @@ -48,16 +53,24 @@ namespace osu.Game.Rulesets.Taiko.UI VisualisationMethod = ScrollVisualisationMethod.Overlapping; } - [BackgroundDependencyLoader] - private void load() + [BackgroundDependencyLoader(true)] + private void load(GameplayState gameplayState) { new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar)); - FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Scroller), _ => Empty()) + var spriteElements = gameplayState.Storyboard.Layers.Where(l => l.Name != @"Overlay") + .SelectMany(l => l.Elements) + .OfType() + .DistinctBy(e => e.Path); + + if (spriteElements.Count() < 10) { - RelativeSizeAxes = Axes.X, - Depth = float.MaxValue - }); + FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Scroller), _ => Empty()) + { + RelativeSizeAxes = Axes.X, + Depth = float.MaxValue, + }); + } KeyBindingInputManager.Add(new DrumTouchInputArea()); } @@ -76,7 +89,9 @@ namespace osu.Game.Rulesets.Taiko.UI base.UpdateAfterChildren(); var playfieldScreen = Playfield.ScreenSpaceDrawQuad; - scroller.Height = ToLocalSpace(playfieldScreen.TopLeft + new Vector2(0, playfieldScreen.Height / 20)).Y; + + if (scroller != null) + scroller.Height = ToLocalSpace(playfieldScreen.TopLeft + new Vector2(0, playfieldScreen.Height / 20)).Y; } public MultiplierControlPoint ControlPointAt(double time) From 4b0b0735a81aec44fe0725eac3be8ece7b733854 Mon Sep 17 00:00:00 2001 From: Salman Ahmed Date: Thu, 29 Feb 2024 06:03:57 +0300 Subject: [PATCH 41/58] Add test coverage --- .../TestSceneTaikoPlayerScroller.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerScroller.cs diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerScroller.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerScroller.cs new file mode 100644 index 0000000000..c2aa819c3a --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerScroller.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Skinning.Legacy; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneTaikoPlayerScroller : LegacySkinPlayerTestScene + { + private Storyboard? currentStoryboard; + + protected override bool HasCustomSteps => true; + + [Test] + public void TestForegroundSpritesHidesScroller() + { + AddStep("load storyboard", () => + { + currentStoryboard = new Storyboard(); + + for (int i = 0; i < 10; i++) + currentStoryboard.GetLayer("Foreground").Add(new StoryboardSprite($"test{i}", Anchor.Centre, Vector2.Zero)); + }); + + CreateTest(); + AddAssert("taiko scroller not present", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestOverlaySpritesKeepsScroller() + { + AddStep("load storyboard", () => + { + currentStoryboard = new Storyboard(); + + for (int i = 0; i < 10; i++) + currentStoryboard.GetLayer("Overlay").Add(new StoryboardSprite($"test{i}", Anchor.Centre, Vector2.Zero)); + }); + + CreateTest(); + AddAssert("taiko scroller present", () => this.ChildrenOfType().Single().IsPresent); + } + + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + => base.CreateWorkingBeatmap(beatmap, currentStoryboard ?? storyboard); + } +} From dac8f98ea6b7d6f039d5fbe9b86b17a4303969cb Mon Sep 17 00:00:00 2001 From: Salman Ahmed Date: Thu, 29 Feb 2024 07:13:32 +0300 Subject: [PATCH 42/58] Fix `GameplayState` not handled as nullable --- osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index ff969c3f74..b8e76be89e 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -54,14 +54,14 @@ namespace osu.Game.Rulesets.Taiko.UI } [BackgroundDependencyLoader(true)] - private void load(GameplayState gameplayState) + private void load([CanBeNull] GameplayState gameplayState) { new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar)); - var spriteElements = gameplayState.Storyboard.Layers.Where(l => l.Name != @"Overlay") + var spriteElements = gameplayState?.Storyboard.Layers.Where(l => l.Name != @"Overlay") .SelectMany(l => l.Elements) .OfType() - .DistinctBy(e => e.Path); + .DistinctBy(e => e.Path) ?? Enumerable.Empty(); if (spriteElements.Count() < 10) { From 5495c2090a76bfd6f6a5efd0fd61530395cc6a68 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 14:15:29 +0800 Subject: [PATCH 43/58] Add test coverage of gameplay only running forwards --- .../Visual/Gameplay/TestScenePause.cs | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 73aa3be73d..030f2592ed 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -16,6 +16,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Rulesets; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK; @@ -31,6 +32,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Container Content => content; + private bool gameplayClockAlwaysGoingForward = true; + private double lastForwardCheckTime; + public TestScenePause() { base.Content.Add(content = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }); @@ -67,12 +71,20 @@ namespace osu.Game.Tests.Visual.Gameplay confirmPausedWithNoOverlay(); } + [Test] + public void TestForwardPlaybackGuarantee() + { + hookForwardPlaybackCheck(); + + AddUntilStep("wait for forward playback", () => Player.GameplayClockContainer.CurrentTime > 1000); + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + + checkForwardPlayback(); + } + [Test] public void TestPauseWithLargeOffset() { - double lastStopTime; - bool alwaysGoingForward = true; - AddStep("force large offset", () => { var offset = (BindableDouble)LocalConfig.GetBindable(OsuSetting.AudioOffset); @@ -82,25 +94,7 @@ namespace osu.Game.Tests.Visual.Gameplay offset.Value = -5000; }); - AddStep("add time forward check hook", () => - { - lastStopTime = double.MinValue; - alwaysGoingForward = true; - - Player.OnUpdate += _ => - { - var masterClock = (MasterGameplayClockContainer)Player.GameplayClockContainer; - - double currentTime = masterClock.CurrentTime; - - bool goingForward = currentTime >= lastStopTime; - - alwaysGoingForward &= goingForward; - - if (!goingForward) - Logger.Log($"Went too far backwards (last stop: {lastStopTime:N1} current: {currentTime:N1})"); - }; - }); + hookForwardPlaybackCheck(); AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); @@ -108,11 +102,37 @@ namespace osu.Game.Tests.Visual.Gameplay resumeAndConfirm(); - AddAssert("time didn't go too far backwards", () => alwaysGoingForward); + checkForwardPlayback(); AddStep("reset offset", () => LocalConfig.SetValue(OsuSetting.AudioOffset, 0.0)); } + private void checkForwardPlayback() => AddAssert("time didn't go too far backwards", () => gameplayClockAlwaysGoingForward); + + private void hookForwardPlaybackCheck() + { + AddStep("add time forward check hook", () => + { + lastForwardCheckTime = double.MinValue; + gameplayClockAlwaysGoingForward = true; + + Player.OnUpdate += _ => + { + var frameStableClock = Player.ChildrenOfType().Single().Clock; + + double currentTime = frameStableClock.CurrentTime; + + bool goingForward = currentTime >= lastForwardCheckTime; + lastForwardCheckTime = currentTime; + + gameplayClockAlwaysGoingForward &= goingForward; + + if (!goingForward) + Logger.Log($"Went too far backwards (last stop: {lastForwardCheckTime:N1} current: {currentTime:N1})"); + }; + }); + } + [Test] public void TestPauseResume() { From 76e8aee9ccc13637691918de7c98d0cfaa8d1a7d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 13:45:46 +0800 Subject: [PATCH 44/58] Disallow backwards seeks during frame stable operation when a replay is not attached --- osu.Game/Rulesets/UI/FrameStabilityContainer.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 8c9cb262af..4011034396 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -150,6 +150,17 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.NotValid; } + // This is a hotfix for https://github.com/ppy/osu/issues/26879 while we figure how the hell time is seeking + // backwards by 11,850 ms for some users during gameplay. + // + // It basically says that "while we're running in frame stable mode, and don't have a replay attached, + // time should never go backwards". If it does, we stop running gameplay until it returns to normal. + if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime) + { + state = PlaybackState.NotValid; + return; + } + // if the proposed time is the same as the current time, assume that the clock will continue progressing in the same direction as previously. // this avoids spurious flips in direction from -1 to 1 during rewinds. if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime) From 3355764a68535facc627433f7c58d1040f3928e2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 18:21:44 +0800 Subject: [PATCH 45/58] "Fix" tests --- osu.Game/Rulesets/UI/FrameStabilityContainer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 4011034396..487c12830f 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; @@ -155,7 +156,7 @@ namespace osu.Game.Rulesets.UI // // It basically says that "while we're running in frame stable mode, and don't have a replay attached, // time should never go backwards". If it does, we stop running gameplay until it returns to normal. - if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime) + if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !DebugUtils.IsNUnitRunning) { state = PlaybackState.NotValid; return; From 3a780e2b676eee99f0b0e845c12348977df22695 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 18:27:28 +0800 Subject: [PATCH 46/58] Add logging when workaround is hit --- osu.Game/Rulesets/UI/FrameStabilityContainer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 487c12830f..03f3b8788f 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Framework.Timing; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; @@ -158,6 +159,7 @@ namespace osu.Game.Rulesets.UI // time should never go backwards". If it does, we stop running gameplay until it returns to normal. if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !DebugUtils.IsNUnitRunning) { + Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); state = PlaybackState.NotValid; return; } From 4184a5c1ef380318dae27492c0f68c4ba211aa0e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 20:34:38 +0800 Subject: [PATCH 47/58] Add flag to allow backwards seeks in tests --- .../TestSceneTimingBasedNoteColouring.cs | 21 ++++++++++++------- .../NonVisual/FirstAvailableHitWindowsTest.cs | 1 + .../TestSceneCompletionCancellation.cs | 2 ++ .../TestSceneFrameStabilityContainer.cs | 3 +++ .../TestSceneGameplaySamplePlayback.cs | 2 ++ .../TestSceneGameplaySampleTriggerSource.cs | 2 ++ .../Visual/Gameplay/TestSceneHitErrorMeter.cs | 1 + .../Gameplay/TestScenePoolingRuleset.cs | 1 + .../Gameplay/TestSceneStoryboardWithOutro.cs | 2 ++ osu.Game/Rulesets/UI/DrawableRuleset.cs | 20 ++++++++++++++++++ .../Rulesets/UI/FrameStabilityContainer.cs | 5 +++-- osu.Game/Tests/Visual/PlayerTestScene.cs | 12 ++++++++++- 12 files changed, 61 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs index 81557c198d..b5b265792b 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs @@ -34,16 +34,21 @@ namespace osu.Game.Rulesets.Mania.Tests [SetUpSteps] public void SetUpSteps() { - AddStep("setup hierarchy", () => Child = new Container + AddStep("setup hierarchy", () => { - Clock = new FramedClock(clock = new ManualClock()), - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new[] + Child = new Container { - drawableRuleset = (DrawableManiaRuleset)Ruleset.Value.CreateInstance().CreateDrawableRulesetWith(createTestBeatmap()) - } + Clock = new FramedClock(clock = new ManualClock()), + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + drawableRuleset = (DrawableManiaRuleset)Ruleset.Value.CreateInstance().CreateDrawableRulesetWith(createTestBeatmap()) + } + }; + + drawableRuleset.AllowBackwardsSeeks = true; }); AddStep("retrieve config bindable", () => { diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index 0bdd0ceae6..d4b69c1be2 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -100,6 +100,7 @@ namespace osu.Game.Tests.NonVisual public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } internal override bool FrameStablePlayback { get; set; } + public override bool AllowBackwardsSeeks { get; set; } public override IReadOnlyList Mods { get; } public override double GameplayStartTime { get; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs index 434d853992..f19f4b6690 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs @@ -29,6 +29,8 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool AllowFail => false; + protected override bool AllowBackwardsSeeks => true; + [SetUpSteps] public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index 98a97e1d23..0cff675b28 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -131,6 +131,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => mainContainer.Child = new FrameStabilityContainer(gameplayStartTime) + { + AllowBackwardsSeeks = true, + } .WithChild(consumer = new ClockConsumingChild())); private void seekManualTo(double time) => AddStep($"seek manual clock to {time}", () => manualClock.CurrentTime = time); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 3d35860fef..057197e819 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneGameplaySamplePlayback : PlayerTestScene { + protected override bool AllowBackwardsSeeks => true; + [Test] public void TestAllSamplesStopDuringSeek() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index 3cbd5eefac..6981591193 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -28,6 +28,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneGameplaySampleTriggerSource : PlayerTestScene { + protected override bool AllowBackwardsSeeks => true; + private TestGameplaySampleTriggerSource sampleTriggerSource = null!; protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 56900a0549..e57177498d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -288,6 +288,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } internal override bool FrameStablePlayback { get; set; } + public override bool AllowBackwardsSeeks { get; set; } public override IReadOnlyList Mods { get; } public override double GameplayStartTime { get; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index b567e8de8d..88effb4a7b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -269,6 +269,7 @@ namespace osu.Game.Tests.Visual.Gameplay drawableRuleset = (TestDrawablePoolingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); drawableRuleset.FrameStablePlayback = true; + drawableRuleset.AllowBackwardsSeeks = true; drawableRuleset.PoolSize = poolSize; Child = new Container diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 98825b27d4..f532921d63 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -31,6 +31,8 @@ namespace osu.Game.Tests.Visual.Gameplay { protected override bool HasCustomSteps => true; + protected override bool AllowBackwardsSeeks => true; + protected new OutroPlayer Player => (OutroPlayer)base.Player; private double currentBeatmapDuration; diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 4aeb3d4862..13e28279e6 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -81,6 +81,19 @@ namespace osu.Game.Rulesets.UI public override IFrameStableClock FrameStableClock => frameStabilityContainer; + private bool allowBackwardsSeeks; + + public override bool AllowBackwardsSeeks + { + get => allowBackwardsSeeks; + set + { + allowBackwardsSeeks = value; + if (frameStabilityContainer != null) + frameStabilityContainer.AllowBackwardsSeeks = value; + } + } + private bool frameStablePlayback = true; internal override bool FrameStablePlayback @@ -178,6 +191,7 @@ namespace osu.Game.Rulesets.UI InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { FrameStablePlayback = FrameStablePlayback, + AllowBackwardsSeeks = AllowBackwardsSeeks, Children = new Drawable[] { FrameStableComponents, @@ -463,6 +477,12 @@ namespace osu.Game.Rulesets.UI /// internal abstract bool FrameStablePlayback { get; set; } + /// + /// When a replay is not attached, we usually block any backwards seeks. + /// This will bypass the check. Should only be used for tests. + /// + public abstract bool AllowBackwardsSeeks { get; set; } + /// /// The mods which are to be applied. /// diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 03f3b8788f..ab48711955 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; -using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; @@ -26,6 +25,8 @@ namespace osu.Game.Rulesets.UI { public ReplayInputHandler? ReplayInputHandler { get; set; } + public bool AllowBackwardsSeeks { get; set; } + /// /// The number of CPU milliseconds to spend at most during seek catch-up. /// @@ -157,7 +158,7 @@ namespace osu.Game.Rulesets.UI // // It basically says that "while we're running in frame stable mode, and don't have a replay attached, // time should never go backwards". If it does, we stop running gameplay until it returns to normal. - if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !DebugUtils.IsNUnitRunning) + if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !AllowBackwardsSeeks) { Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); state = PlaybackState.NotValid; diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index ee184c1f35..43d779261c 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -70,10 +70,20 @@ namespace osu.Game.Tests.Visual AddStep($"Load player for {CreatePlayerRuleset().Description}", LoadPlayer); AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); + + if (AllowBackwardsSeeks) + { + AddStep("allow backwards seeking", () => + { + Player.DrawableRuleset.AllowBackwardsSeeks = AllowBackwardsSeeks; + }); + } } protected virtual bool AllowFail => false; + protected virtual bool AllowBackwardsSeeks => false; + protected virtual bool Autoplay => false; protected void LoadPlayer() => LoadPlayer(Array.Empty()); @@ -126,6 +136,6 @@ namespace osu.Game.Tests.Visual protected sealed override Ruleset CreateRuleset() => CreatePlayerRuleset(); - protected virtual TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false, false); + protected virtual TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false, false, AllowBackwardsSeeks); } } From cc8b838bd45d5df2be723b6cb1ddd82bec5622b8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 23:03:27 +0800 Subject: [PATCH 48/58] Add comprehensive log output to help figure out problematic clocks --- osu.Game/Beatmaps/FramedBeatmapClock.cs | 26 ++++++++++++++++++- .../Rulesets/UI/FrameStabilityContainer.cs | 7 +++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/osu.Game/Beatmaps/FramedBeatmapClock.cs b/osu.Game/Beatmaps/FramedBeatmapClock.cs index 49dff96ff1..af7be235fc 100644 --- a/osu.Game/Beatmaps/FramedBeatmapClock.cs +++ b/osu.Game/Beatmaps/FramedBeatmapClock.cs @@ -38,6 +38,7 @@ namespace osu.Game.Beatmaps private IDisposable? beatmapOffsetSubscription; private readonly DecouplingFramedClock decoupledTrack; + private readonly InterpolatingFramedClock interpolatedTrack; [Resolved] private OsuConfigManager config { get; set; } = null!; @@ -58,7 +59,7 @@ namespace osu.Game.Beatmaps // An interpolating clock is used to ensure precise time values even when the host audio subsystem is not reporting // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example). - var interpolatedTrack = new InterpolatingFramedClock(decoupledTrack); + interpolatedTrack = new InterpolatingFramedClock(decoupledTrack); if (applyOffsets) { @@ -190,5 +191,28 @@ namespace osu.Game.Beatmaps base.Dispose(isDisposing); beatmapOffsetSubscription?.Dispose(); } + + public string GetSnapshot() + { + return + $"originalSource: {output(Source)}\n" + + $"userGlobalOffsetClock: {output(userGlobalOffsetClock)}\n" + + $"platformOffsetClock: {output(platformOffsetClock)}\n" + + $"userBeatmapOffsetClock: {output(userBeatmapOffsetClock)}\n" + + $"interpolatedTrack: {output(interpolatedTrack)}\n" + + $"decoupledTrack: {output(decoupledTrack)}\n" + + $"finalClockSource: {output(finalClockSource)}\n"; + + string output(IClock? clock) + { + if (clock == null) + return "null"; + + if (clock is IFrameBasedClock framed) + return $"current: {clock.CurrentTime:N2} running: {clock.IsRunning} rate: {clock.Rate} elapsed: {framed.ElapsedFrameTime:N2}"; + + return $"current: {clock.CurrentTime:N2} running: {clock.IsRunning} rate: {clock.Rate}"; + } + } } } diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index ab48711955..c09018e8ca 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -3,13 +3,16 @@ using System; using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Framework.Timing; +using osu.Game.Beatmaps; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; @@ -161,6 +164,10 @@ namespace osu.Game.Rulesets.UI if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !AllowBackwardsSeeks) { Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); + + if (parentGameplayClock is GameplayClockContainer gcc) + Logger.Log($"{gcc.ChildrenOfType().Single().GetSnapshot()}"); + state = PlaybackState.NotValid; return; } From 59b9d29a79c2520cda4b1fca7d2b8373e501c71d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 29 Feb 2024 23:29:50 +0800 Subject: [PATCH 49/58] Fix formatting? --- .../Visual/Gameplay/TestSceneFrameStabilityContainer.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index 0cff675b28..c2999e3f5a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -130,11 +130,12 @@ namespace osu.Game.Tests.Visual.Gameplay } private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => + { mainContainer.Child = new FrameStabilityContainer(gameplayStartTime) - { - AllowBackwardsSeeks = true, - } - .WithChild(consumer = new ClockConsumingChild())); + { + AllowBackwardsSeeks = true, + }.WithChild(consumer = new ClockConsumingChild()); + }); private void seekManualTo(double time) => AddStep($"seek manual clock to {time}", () => manualClock.CurrentTime = time); From eb0933c3a5aa2de47820d020d34cabaf7552e7e6 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Thu, 29 Feb 2024 20:35:20 +0300 Subject: [PATCH 50/58] Fix allocations in EffectPointVisualisation --- .../Summary/Parts/EffectPointVisualisation.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs index d92beba38a..bf87470e01 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.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 System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -53,7 +52,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts // for changes. ControlPointInfo needs a refactor to make this flow better, but it should do for now. Scheduler.AddDelayed(() => { - var next = beatmap.ControlPointInfo.EffectPoints.FirstOrDefault(c => c.Time > effect.Time); + EffectControlPoint? next = null; + + for (int i = 0; i < beatmap.ControlPointInfo.EffectPoints.Count; i++) + { + var point = beatmap.ControlPointInfo.EffectPoints[i]; + + if (point.Time > effect.Time) + { + next = point; + break; + } + } if (!ReferenceEquals(nextControlPoint, next)) { From 61cc5d6f29606c3513f6c5b699849ef155be2f1f Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 1 Mar 2024 11:24:12 +0800 Subject: [PATCH 51/58] Fix typos in xmldoc --- osu.Desktop/Windows/WindowsAssociationManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index c784d52a4f..181403d287 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -180,7 +180,7 @@ namespace osu.Desktop.Windows private string programId => $@"{program_id_prefix}{Extension}"; /// - /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key + /// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key /// public void Install() { @@ -219,7 +219,7 @@ namespace osu.Desktop.Windows } /// - /// Uninstalls the file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#deleting-registry-information-during-uninstallation + /// Uninstalls the file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#deleting-registry-information-during-uninstallation /// public void Uninstall() { From 00527da27d97ceba51f6d8bd46f4ec94542916a5 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 1 Mar 2024 11:42:35 +0800 Subject: [PATCH 52/58] When discord is set to privacy mode, don't show beatmap being edited --- osu.Game/Users/UserActivity.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 1b09666df6..404ed141b9 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -151,7 +151,11 @@ namespace osu.Game.Users public EditingBeatmap() { } public override string GetStatus(bool hideIdentifiableInformation = false) => @"Editing a beatmap"; - public override string GetDetails(bool hideIdentifiableInformation = false) => BeatmapDisplayTitle; + + public override string GetDetails(bool hideIdentifiableInformation = false) => hideIdentifiableInformation + // For now let's assume that showing the beatmap a user is editing could reveal unwanted information. + ? string.Empty + : BeatmapDisplayTitle; } [MessagePackObject] From 4ad8bbb9e2d17c6c35fb26f37004c077af87dddf Mon Sep 17 00:00:00 2001 From: cdwcgt Date: Fri, 1 Mar 2024 13:20:37 +0900 Subject: [PATCH 53/58] remove useless DrawablePool --- osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index 0f2f9dc323..8bb5ee3617 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -107,8 +107,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters JudgementSpacing.BindValueChanged(_ => updateMetrics(), true); } - private readonly DrawablePool judgementLinePool = new DrawablePool(50); - public void Push(HitErrorShape shape) { Add(shape); From 19ed78eef57828c1da4210e5796bd5e7b7fcdb48 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 1 Mar 2024 12:34:21 +0800 Subject: [PATCH 54/58] Log backwards seeks to sentry --- osu.Game/OsuGame.cs | 3 +++ .../Rulesets/UI/FrameStabilityContainer.cs | 15 ++++++++++--- .../Utils/SentryOnlyDiagnosticsException.cs | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 osu.Game/Utils/SentryOnlyDiagnosticsException.cs diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 7d128a808a..eb1219f183 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1190,6 +1190,9 @@ namespace osu.Game { if (entry.Level < LogLevel.Important || entry.Target > LoggingTarget.Database || entry.Target == null) return; + if (entry.Exception is SentryOnlyDiagnosticsException) + return; + const int short_term_display_limit = 3; if (recentLogCount < short_term_display_limit) diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index c09018e8ca..884310e44c 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -15,6 +15,7 @@ using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; +using osu.Game.Utils; namespace osu.Game.Rulesets.UI { @@ -29,6 +30,7 @@ namespace osu.Game.Rulesets.UI public ReplayInputHandler? ReplayInputHandler { get; set; } public bool AllowBackwardsSeeks { get; set; } + private double? lastBackwardsSeekLogTime; /// /// The number of CPU milliseconds to spend at most during seek catch-up. @@ -163,10 +165,17 @@ namespace osu.Game.Rulesets.UI // time should never go backwards". If it does, we stop running gameplay until it returns to normal. if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !AllowBackwardsSeeks) { - Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); + if (lastBackwardsSeekLogTime == null || Math.Abs(Clock.CurrentTime - lastBackwardsSeekLogTime.Value) > 1000) + { + lastBackwardsSeekLogTime = Clock.CurrentTime; - if (parentGameplayClock is GameplayClockContainer gcc) - Logger.Log($"{gcc.ChildrenOfType().Single().GetSnapshot()}"); + string loggableContent = $"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"; + + if (parentGameplayClock is GameplayClockContainer gcc) + loggableContent += $"\n{gcc.ChildrenOfType().Single().GetSnapshot()}"; + + Logger.Error(new SentryOnlyDiagnosticsException("backwards seek"), loggableContent); + } state = PlaybackState.NotValid; return; diff --git a/osu.Game/Utils/SentryOnlyDiagnosticsException.cs b/osu.Game/Utils/SentryOnlyDiagnosticsException.cs new file mode 100644 index 0000000000..1659b8a213 --- /dev/null +++ b/osu.Game/Utils/SentryOnlyDiagnosticsException.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 System; + +namespace osu.Game.Utils +{ + /// + /// Log to sentry without showing an error notification to the user. + /// + /// + /// This can be used to convey important diagnostics to us developers without + /// getting in the user's way. Should be used sparingly. + internal class SentryOnlyDiagnosticsException : Exception + { + public SentryOnlyDiagnosticsException(string message) + : base(message) + { + } + } +} From 963c0af8143654e20910ed4f6b48ebce5845ad4e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 1 Mar 2024 16:43:47 +0800 Subject: [PATCH 55/58] Fix beatmap information still showing when testing a beatmap --- osu.Game/Users/UserActivity.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 404ed141b9..a5dd2cb37c 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -119,10 +119,10 @@ namespace osu.Game.Users } [MessagePackObject] - public class TestingBeatmap : InGame + public class TestingBeatmap : EditingBeatmap { public TestingBeatmap(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) - : base(beatmapInfo, ruleset) + : base(beatmapInfo) { } From c6201ea5de4bc0ad9943fd5e5f83783e6172205d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 1 Mar 2024 20:28:52 +0800 Subject: [PATCH 56/58] Remove unused ruleset parameter when testing beatmap in editor --- osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs | 3 +-- osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs | 2 +- osu.Game/Users/UserActivity.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 4df34e6244..91942c391a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -14,7 +14,6 @@ using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Tests.Beatmaps; using osu.Game.Users; @@ -142,7 +141,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("choosing", () => activity.Value = new UserActivity.ChoosingBeatmap()); AddStep("editing beatmap", () => activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo())); AddStep("modding beatmap", () => activity.Value = new UserActivity.ModdingBeatmap(new BeatmapInfo())); - AddStep("testing beatmap", () => activity.Value = new UserActivity.TestingBeatmap(new BeatmapInfo(), new OsuRuleset().RulesetInfo)); + AddStep("testing beatmap", () => activity.Value = new UserActivity.TestingBeatmap(new BeatmapInfo())); } [Test] diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index 7dff05667d..55607cbb7c 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Edit.GameplayTest private readonly Editor editor; private readonly EditorState editorState; - protected override UserActivity InitialActivity => new UserActivity.TestingBeatmap(Beatmap.Value.BeatmapInfo, Ruleset.Value); + protected override UserActivity InitialActivity => new UserActivity.TestingBeatmap(Beatmap.Value.BeatmapInfo); [Resolved] private MusicController musicController { get; set; } = null!; diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index a5dd2cb37c..a431b204bc 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -121,7 +121,7 @@ namespace osu.Game.Users [MessagePackObject] public class TestingBeatmap : EditingBeatmap { - public TestingBeatmap(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) + public TestingBeatmap(IBeatmapInfo beatmapInfo) : base(beatmapInfo) { } From 3df32638c2235029625d929c41ce6237c646e75c Mon Sep 17 00:00:00 2001 From: Susko3 Date: Fri, 1 Mar 2024 16:06:30 +0100 Subject: [PATCH 57/58] Fix association descriptions never being written on update --- osu.Desktop/Windows/WindowsAssociationManager.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs index 181403d287..11b5c19ca1 100644 --- a/osu.Desktop/Windows/WindowsAssociationManager.cs +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -82,6 +82,10 @@ namespace osu.Desktop.Windows try { updateAssociations(); + + // TODO: Remove once UpdateDescriptions() is called as specified in the xmldoc. + updateDescriptions(null); // always write default descriptions, in case of updating from an older version in which file associations were not implemented/installed + NotifyShellUpdate(); } catch (Exception e) From 85f131d2f87f7da733c542a38ae8cd6804a43f89 Mon Sep 17 00:00:00 2001 From: Salman Ahmed Date: Sat, 2 Mar 2024 22:45:15 +0300 Subject: [PATCH 58/58] Fix "unranked explaination" tooltip text using incorrect translation key --- osu.Game/Localisation/ModSelectOverlayStrings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index 9513eacf02..7a9bb698d8 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -67,7 +67,7 @@ namespace osu.Game.Localisation /// /// "Performance points will not be granted due to active mods." /// - public static LocalisableString UnrankedExplanation => new TranslatableString(getKey(@"ranked_explanation"), @"Performance points will not be granted due to active mods."); + public static LocalisableString UnrankedExplanation => new TranslatableString(getKey(@"unranked_explanation"), @"Performance points will not be granted due to active mods."); private static string getKey(string key) => $@"{prefix}:{key}"; }