From 1beb3f5462329262b902472227a3b6c097714092 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 4 Jan 2024 15:45:13 +0900 Subject: [PATCH 01/70] Add failing test coverage of fast streams not working in editor --- .../TestSceneEditorAutoplayFastStreams.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 osu.Game.Rulesets.Osu.Tests/Editor/TestSceneEditorAutoplayFastStreams.cs diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneEditorAutoplayFastStreams.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneEditorAutoplayFastStreams.cs new file mode 100644 index 000000000000..cf5cd809efdb --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneEditorAutoplayFastStreams.cs @@ -0,0 +1,51 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + /// + /// This test covers autoplay working correctly in the editor on fast streams. + /// Might seem like a weird test, but frame stability being toggled can cause autoplay to operation incorrectly. + /// This is clearly a bug with the autoplay algorithm, but is worked around at an editor level for now. + /// + public partial class TestSceneEditorAutoplayFastStreams : EditorTestScene + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var testBeatmap = new TestBeatmap(ruleset, false); + testBeatmap.HitObjects.AddRange(new[] + { + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 530 }, + new HitCircle { StartTime = 560 }, + new HitCircle { StartTime = 590 }, + new HitCircle { StartTime = 620 }, + }); + + return testBeatmap; + } + + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestAllHit() + { + AddStep("start playback", () => EditorClock.Start()); + AddUntilStep("wait for all hit", () => + { + DrawableHitCircle[] hitCircles = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).ToArray(); + + return hitCircles.Length == 5 && hitCircles.All(h => h.IsHit); + }); + } + } +} From 65c29b4f09d49d772cfe2c9934c4d1ee65e9b384 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 4 Jan 2024 13:18:41 +0900 Subject: [PATCH 02/70] Make editor remain frame stable during normal playback --- .../Edit/DrawableEditorRulesetWrapper.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index 174b278d8991..ebf06bcc4e44 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; @@ -26,6 +27,9 @@ internal partial class DrawableEditorRulesetWrapper : CompositeDrawable [Resolved] private EditorBeatmap beatmap { get; set; } = null!; + [Resolved] + private EditorClock editorClock { get; set; } = null!; + public DrawableEditorRulesetWrapper(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; @@ -38,7 +42,6 @@ public DrawableEditorRulesetWrapper(DrawableRuleset drawableRuleset) [BackgroundDependencyLoader] private void load() { - drawableRuleset.FrameStablePlayback = false; Playfield.DisplayJudgements.Value = false; } @@ -65,6 +68,22 @@ protected override void LoadComplete() Scheduler.AddOnce(regenerateAutoplay); } + protected override void Update() + { + base.Update(); + + // Whenever possible, we want to stay in frame stability playback. + // Without doing so, we run into bugs with some gameplay elements not behaving as expected. + // + // Note that this is not using EditorClock.IsSeeking as that would exit frame stability + // on all seeks. The intention here is to retain frame stability for small seeks. + // + // I still think no gameplay elements should require frame stability in the first place, but maybe that ship has sailed already.. + bool shouldBypassFrameStability = Math.Abs(drawableRuleset.FrameStableClock.CurrentTime - editorClock.CurrentTime) > 1000; + + drawableRuleset.FrameStablePlayback = !shouldBypassFrameStability; + } + private void regenerateAutoplay() { var autoplayMod = drawableRuleset.Mods.OfType().Single(); From 3419c59b062ba0ac9670d9279029d6fbb062aac0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 4 Jan 2024 15:33:18 +0900 Subject: [PATCH 03/70] Fix failing tests due to frame stable seeks taking longer --- .../Editor/TestSceneOsuComposerSelection.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs index 623cefff6bcd..366f17daeea0 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs @@ -46,10 +46,12 @@ public void TestSelectAfterFadedOut() moveMouseToObject(() => slider); AddStep("seek after end", () => EditorClock.Seek(750)); + AddUntilStep("wait for seek", () => !EditorClock.IsSeeking); AddStep("left click", () => InputManager.Click(MouseButton.Left)); AddAssert("slider not selected", () => EditorBeatmap.SelectedHitObjects.Count == 0); AddStep("seek to visible", () => EditorClock.Seek(650)); + AddUntilStep("wait for seek", () => !EditorClock.IsSeeking); AddStep("left click", () => InputManager.Click(MouseButton.Left)); AddUntilStep("slider selected", () => EditorBeatmap.SelectedHitObjects.Single() == slider); } From adac3b65cea7a896907ed5451abedc1e241e866f Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 4 Jan 2024 18:48:13 +0900 Subject: [PATCH 04/70] Fix beatmap carousel not preloading panels when off-screen --- osu.Game/Screens/Select/BeatmapCarousel.cs | 4 +- .../Carousel/DrawableCarouselBeatmapSet.cs | 73 +++++++++++++------ 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 89911c9a698d..3353aeed7d1d 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -96,12 +96,12 @@ public partial class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler /// Extend the range to retain already loaded pooled drawables. /// - private const float distance_offscreen_before_unload = 1024; + private const float distance_offscreen_before_unload = 2048; /// /// Extend the range to update positions / retrieve pooled drawables outside of visible range. /// - private const float distance_offscreen_to_preload = 512; // todo: adjust this appropriately once we can make set panel contents load while off-screen. + private const float distance_offscreen_to_preload = 768; /// /// Whether carousel items have completed asynchronously loaded. diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index f16e92a82a0b..c24e09582e20 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -46,6 +47,8 @@ public partial class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasCont private MenuItem[]? mainMenuItems; + private double timeSinceUnpool; + [Resolved] private BeatmapManager manager { get; set; } = null!; @@ -54,6 +57,7 @@ protected override void FreeAfterUse() base.FreeAfterUse(); Item = null; + timeSinceUnpool = 0; ClearTransforms(); } @@ -92,13 +96,21 @@ protected override void Update() // algorithm for this is taken from ScrollContainer. // while it doesn't necessarily need to match 1:1, as we are emulating scroll in some cases this feels most correct. Y = (float)Interpolation.Lerp(targetY, Y, Math.Exp(-0.01 * Time.Elapsed)); + + loadContentIfRequired(); } + private CancellationTokenSource? loadCancellation; + protected override void UpdateItem() { + loadCancellation?.Cancel(); + loadCancellation = null; + base.UpdateItem(); Content.Clear(); + Header.Clear(); beatmapContainer = null; beatmapsLoadTask = null; @@ -107,32 +119,8 @@ protected override void UpdateItem() return; beatmapSet = ((CarouselBeatmapSet)Item).BeatmapSet; - - DelayedLoadWrapper background; - DelayedLoadWrapper mainFlow; - - Header.Children = new Drawable[] - { - // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). - background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) - { - RelativeSizeAxes = Axes.Both, - }, 200) - { - RelativeSizeAxes = Axes.Both - }, - mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 50) - { - RelativeSizeAxes = Axes.Both - }, - }; - - background.DelayedLoadComplete += fadeContentIn; - mainFlow.DelayedLoadComplete += fadeContentIn; } - private void fadeContentIn(Drawable d) => d.FadeInFromZero(150); - protected override void Deselected() { base.Deselected(); @@ -190,6 +178,43 @@ private void updateBeatmapDifficulties() } } + private void loadContentIfRequired() + { + // Using DelayedLoadWrappers would only allow us to load content when on screen, but we want to preload while off-screen + // to provide a better user experience. + + // This is tracking time that this drawable is updating since the last pool. + // This is intended to provide a debounce so very fast scrolls (from one end to the other of the carousel) + // don't cause huge overheads. + const double time_updating_before_load = 150; + + Debug.Assert(Item != null); + + if (loadCancellation == null && (timeSinceUnpool += Time.Elapsed) > time_updating_before_load) + { + loadCancellation = new CancellationTokenSource(); + + LoadComponentAsync(new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) + { + RelativeSizeAxes = Axes.Both, + }, background => + { + Header.Add(background); + background.FadeInFromZero(150); + }, loadCancellation.Token); + + LoadComponentAsync(new SetPanelContent((CarouselBeatmapSet)Item) + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + }, mainFlow => + { + Header.Add(mainFlow); + mainFlow.FadeInFromZero(150); + }, loadCancellation.Token); + } + } + private void updateBeatmapYPositions() { if (beatmapContainer == null) From 81c6fd5589751e125a8728ad57eb286dc4b146f1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 4 Jan 2024 19:13:36 +0900 Subject: [PATCH 05/70] Load items closer to the centre of the screen as a priority --- osu.Game/Screens/Select/BeatmapCarousel.cs | 3 +- .../Carousel/DrawableCarouselBeatmapSet.cs | 41 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 3353aeed7d1d..44086347874a 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -108,6 +108,7 @@ public partial class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler public bool BeatmapSetsLoaded { get; private set; } + [Cached] protected readonly CarouselScrollContainer Scroll; private readonly NoResultsPlaceholder noResultsPlaceholder; @@ -1251,7 +1252,7 @@ protected override void PerformSelection() } } - protected partial class CarouselScrollContainer : UserTrackingScrollContainer + public partial class CarouselScrollContainer : UserTrackingScrollContainer { private bool rightMouseScrollBlocked; diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index c24e09582e20..61658526db2e 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -8,9 +8,11 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -178,39 +180,44 @@ private void updateBeatmapDifficulties() } } + [Resolved] + private BeatmapCarousel.CarouselScrollContainer scrollContainer { get; set; } = null!; + private void loadContentIfRequired() { + Quad containingSsdq = scrollContainer.ScreenSpaceDrawQuad; + // Using DelayedLoadWrappers would only allow us to load content when on screen, but we want to preload while off-screen // to provide a better user experience. // This is tracking time that this drawable is updating since the last pool. // This is intended to provide a debounce so very fast scrolls (from one end to the other of the carousel) // don't cause huge overheads. - const double time_updating_before_load = 150; + // + // We increase the delay based on distance from centre, so the beatmaps the user is currently looking at load first. + float timeUpdatingBeforeLoad = 50 + Math.Abs(containingSsdq.Centre.Y - ScreenSpaceDrawQuad.Centre.Y) / containingSsdq.Height * 100; Debug.Assert(Item != null); - if (loadCancellation == null && (timeSinceUnpool += Time.Elapsed) > time_updating_before_load) + if (loadCancellation == null && (timeSinceUnpool += Time.Elapsed) > timeUpdatingBeforeLoad) { loadCancellation = new CancellationTokenSource(); - LoadComponentAsync(new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) - { - RelativeSizeAxes = Axes.Both, - }, background => - { - Header.Add(background); - background.FadeInFromZero(150); - }, loadCancellation.Token); - - LoadComponentAsync(new SetPanelContent((CarouselBeatmapSet)Item) + LoadComponentsAsync(new CompositeDrawable[] { - Depth = float.MinValue, - RelativeSizeAxes = Axes.Both, - }, mainFlow => + new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) + { + RelativeSizeAxes = Axes.Both, + }, + new SetPanelContent((CarouselBeatmapSet)Item) + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + } + }, drawables => { - Header.Add(mainFlow); - mainFlow.FadeInFromZero(150); + Header.AddRange(drawables); + drawables.ForEach(d => d.FadeInFromZero(150)); }, loadCancellation.Token); } } From 9b734bac2515c5a8cf61ca3cc7576bf1e012a4b9 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 5 Jan 2024 01:14:34 +1000 Subject: [PATCH 06/70] Allow track control after intro screen finishes. --- osu.Game/Overlays/MusicController.cs | 2 +- osu.Game/Screens/Menu/MainMenu.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 0986c0513ca8..01086d3e3307 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -43,7 +43,7 @@ public partial class MusicController : CompositeDrawable /// /// Whether user control of the global track should be allowed. /// - public readonly BindableBool AllowTrackControl = new BindableBool(true); + public readonly BindableBool AllowTrackControl = new BindableBool(false); /// /// Fired when the global has changed. diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 516b090a1671..ffb68367c2f2 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -241,6 +241,8 @@ public override void OnEntering(ScreenTransitionEvent e) { var track = musicController.CurrentTrack; + musicController.AllowTrackControl.Value = true; + // presume the track is the current beatmap's track. not sure how correct this assumption is but it has worked until now. if (!track.IsRunning) { From 91bb3f6c57d7263876d5de57bab01c5d4b444b9c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 01:24:00 +0900 Subject: [PATCH 07/70] Cache argon character glyph lookups to reduce string allocations --- .../Play/HUD/ArgonCounterTextComponent.cs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index 2a3f4365cb9a..266dfb3301f3 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -137,33 +139,48 @@ public ArgonCounterSpriteText(Func getLookup) [BackgroundDependencyLoader] private void load(TextureStore textures) { + const string font_name = @"argon-counter"; + Spacing = new Vector2(-2f, 0f); - Font = new FontUsage(@"argon-counter", 1); - glyphStore = new GlyphStore(textures, getLookup); + Font = new FontUsage(font_name, 1); + glyphStore = new GlyphStore(font_name, textures, getLookup); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); private class GlyphStore : ITexturedGlyphLookupStore { + private readonly string fontName; private readonly TextureStore textures; private readonly Func getLookup; - public GlyphStore(TextureStore textures, Func getLookup) + private readonly Dictionary cache = new Dictionary(); + + public GlyphStore(string fontName, TextureStore textures, Func getLookup) { + this.fontName = fontName; this.textures = textures; this.getLookup = getLookup; } public ITexturedCharacterGlyph? Get(string? fontName, char character) { + // We only service one font. + Debug.Assert(fontName == this.fontName); + + if (cache.TryGetValue(character, out var cached)) + return cached; + string lookup = getLookup(character); var texture = textures.Get($"Gameplay/Fonts/{fontName}-{lookup}"); - if (texture == null) - return null; + TexturedCharacterGlyph? glyph = null; + + if (texture != null) + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 0.125f); - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 0.125f); + cache[character] = glyph; + return glyph; } public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); From 5b55ca66920b40b394b0403f92b7f371c35cf6b9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 02:26:26 +0900 Subject: [PATCH 08/70] Cache legacy skin character glyph lookups to reduce string allocations --- osu.Game/Skinning/LegacySpriteText.cs | 35 +++++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 8aefa5025233..81db5fdf3635 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -44,10 +46,11 @@ public LegacySpriteText(LegacyFont font) [BackgroundDependencyLoader] private void load(ISkinSource skin) { - base.Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: FixedWidth); + string fontPrefix = skin.GetFontPrefix(font); + base.Font = new FontUsage(fontPrefix, 1, fixedWidth: FixedWidth); Spacing = new Vector2(-skin.GetFontOverlap(font), 0); - glyphStore = new LegacyGlyphStore(skin, MaxSizePerGlyph); + glyphStore = new LegacyGlyphStore(fontPrefix, skin, MaxSizePerGlyph); } protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); @@ -57,25 +60,41 @@ private class LegacyGlyphStore : ITexturedGlyphLookupStore private readonly ISkin skin; private readonly Vector2? maxSize; - public LegacyGlyphStore(ISkin skin, Vector2? maxSize) + private readonly string fontName; + + private readonly Dictionary cache = new Dictionary(); + + public LegacyGlyphStore(string fontName, ISkin skin, Vector2? maxSize) { + this.fontName = fontName; this.skin = skin; this.maxSize = maxSize; } public ITexturedCharacterGlyph? Get(string? fontName, char character) { + // We only service one font. + Debug.Assert(fontName == this.fontName); + + if (cache.TryGetValue(character, out var cached)) + return cached; + string lookup = getLookupName(character); var texture = skin.GetTexture($"{fontName}-{lookup}"); - if (texture == null) - return null; + TexturedCharacterGlyph? glyph = null; - if (maxSize != null) - texture = texture.WithMaximumSize(maxSize.Value); + if (texture != null) + { + if (maxSize != null) + texture = texture.WithMaximumSize(maxSize.Value); + + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + } - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + cache[character] = glyph; + return glyph; } private static string getLookupName(char character) From e9289cfbe78f318d00c9b77065daca6fa824cb09 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 01:51:20 +0900 Subject: [PATCH 09/70] Reduce precision of audio balance adjustments during slider sliding --- osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 1daaa24d5723..bce28361cb81 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -599,7 +599,9 @@ protected double CalculateSamplePlaybackBalance(double position) float balanceAdjustAmount = positionalHitsoundsLevel.Value * 2; double returnedValue = balanceAdjustAmount * (position - 0.5f); - return returnedValue; + // Rounded to reduce the overhead of audio adjustments (which are currently bindable heavy). + // Balance is very hard to perceive in small increments anyways. + return Math.Round(returnedValue, 2); } /// From 091241634c08aaea3b6d3e34762fb2f1cb459295 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 5 Jan 2024 23:55:17 +1000 Subject: [PATCH 10/70] Make IntroScreen set `AllowTrackControl` to false instead --- osu.Game/Overlays/MusicController.cs | 2 +- osu.Game/Screens/Menu/IntroScreen.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 01086d3e3307..0986c0513ca8 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -43,7 +43,7 @@ public partial class MusicController : CompositeDrawable /// /// Whether user control of the global track should be allowed. /// - public readonly BindableBool AllowTrackControl = new BindableBool(false); + public readonly BindableBool AllowTrackControl = new BindableBool(true); /// /// Fired when the global has changed. diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index de7732dd5e33..a81e24ee88cd 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -109,6 +109,8 @@ private void load(OsuConfigManager config, Framework.Game game, RealmAccess real // prevent user from changing beatmap while the intro is still running. beatmap = Beatmap.BeginLease(false); + musicController.AllowTrackControl.Value = false; + MenuVoice = config.GetBindable(OsuSetting.MenuVoice); MenuMusic = config.GetBindable(OsuSetting.MenuMusic); From 8295ad1feb19c30ae944e2f8ea9bd3c5df6d04ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 2 Jan 2024 21:58:44 +0100 Subject: [PATCH 11/70] Change catch scoring to match score V2 --- .../Scoring/CatchScoreProcessor.cs | 60 ++++++++++++++++++- osu.Game/Rulesets/Scoring/ScoreProcessor.cs | 24 ++++---- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index 4b3d378889bc..161a59c5fdf5 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -20,20 +21,73 @@ public partial class CatchScoreProcessor : ScoreProcessor private const int combo_cap = 200; private const double combo_base = 4; + private double fruitTinyScale; + public CatchScoreProcessor() : base(new CatchRuleset()) { } + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + // large ticks are *purposefully* not counted to match stable + int fruitTinyScaleDivisor = MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit) + MaximumResultCounts.GetValueOrDefault(HitResult.Great); + fruitTinyScale = fruitTinyScaleDivisor == 0 + ? 0 + : (double)MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit) / fruitTinyScaleDivisor; + } + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { - return 600000 * comboProgress - + 400000 * Accuracy.Value * accuracyProgress + const int max_tiny_droplets_portion = 400000; + + double comboPortion = 1000000 - max_tiny_droplets_portion + max_tiny_droplets_portion * (1 - fruitTinyScale); + double dropletsPortion = max_tiny_droplets_portion * fruitTinyScale; + double dropletsHit = MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit) == 0 + ? 0 + : (double)ScoreResultCounts.GetValueOrDefault(HitResult.SmallTickHit) / MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit); + + return comboPortion * comboProgress + + dropletsPortion * dropletsHit + bonusPortion; } + public override int GetBaseScoreForResult(HitResult result) + { + switch (result) + { + // dirty hack to emulate accuracy on stable weighting every object equally in accuracy portion + case HitResult.Great: + case HitResult.LargeTickHit: + case HitResult.SmallTickHit: + return 300; + + case HitResult.LargeBonus: + return 200; + } + + return base.GetBaseScoreForResult(result); + } + protected override double GetComboScoreChange(JudgementResult result) - => GetBaseScoreForResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base)); + { + double baseIncrease = 0; + + switch (result.Type) + { + case HitResult.Great: + baseIncrease = 300; + break; + + case HitResult.LargeTickHit: + baseIncrease = 100; + break; + } + + return baseIncrease * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base)); + } public override ScoreRank RankFromAccuracy(double accuracy) { diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 837bb4080e34..869ad2c4ae7e 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -167,14 +167,14 @@ public Dictionary MaximumStatistics if (!beatmapApplied) throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}."); - return new Dictionary(maximumResultCounts); + return new Dictionary(MaximumResultCounts); } } private bool beatmapApplied; - private readonly Dictionary scoreResultCounts = new Dictionary(); - private readonly Dictionary maximumResultCounts = new Dictionary(); + protected readonly Dictionary ScoreResultCounts = new Dictionary(); + protected readonly Dictionary MaximumResultCounts = new Dictionary(); private readonly List hitEvents = new List(); private HitObject? lastHitObject; @@ -216,7 +216,7 @@ protected sealed override void ApplyResultInternal(JudgementResult result) if (result.FailedAtJudgement) return; - scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; + ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1; if (result.Type.IncreasesCombo()) Combo.Value++; @@ -272,7 +272,7 @@ protected sealed override void RevertResultInternal(JudgementResult result) if (result.FailedAtJudgement) return; - scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; + ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1; if (result.Judgement.MaxResult.AffectsAccuracy()) { @@ -394,13 +394,13 @@ protected override void Reset(bool storeResults) maximumComboPortion = currentComboPortion; maximumAccuracyJudgementCount = currentAccuracyJudgementCount; - maximumResultCounts.Clear(); - maximumResultCounts.AddRange(scoreResultCounts); + MaximumResultCounts.Clear(); + MaximumResultCounts.AddRange(ScoreResultCounts); MaximumTotalScore = TotalScore.Value; } - scoreResultCounts.Clear(); + ScoreResultCounts.Clear(); currentBaseScore = 0; currentMaximumBaseScore = 0; @@ -430,10 +430,10 @@ public virtual void PopulateScore(ScoreInfo score) score.MaximumStatistics.Clear(); foreach (var result in HitResultExtensions.ALL_TYPES) - score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result); + score.Statistics[result] = ScoreResultCounts.GetValueOrDefault(result); foreach (var result in HitResultExtensions.ALL_TYPES) - score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); + score.MaximumStatistics[result] = MaximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. score.TotalScore = TotalScore.Value; @@ -464,8 +464,8 @@ public override void ResetFromReplayFrame(ReplayFrame frame) HighestCombo.Value = frame.Header.MaxCombo; TotalScore.Value = frame.Header.TotalScore; - scoreResultCounts.Clear(); - scoreResultCounts.AddRange(frame.Header.Statistics); + ScoreResultCounts.Clear(); + ScoreResultCounts.AddRange(frame.Header.Statistics); SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics); From ea7078fab52bc1db38f4311c8822e99fa6e82e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 3 Jan 2024 15:03:06 +0100 Subject: [PATCH 12/70] Implement approximate score conversion algorithm matching score V2 --- .../StandardisedScoreMigrationTools.cs | 113 +++++++++++++++++- osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs | 3 +- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 9cfb9ea95756..f029d85aed0c 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -437,9 +437,30 @@ double lowerEstimateOfComboPortionInStandardisedScore break; case 2: + // compare logic in `CatchScoreProcessor`. + + // this could technically be slightly incorrect in the case of stable scores. + // because large droplet misses are counted as full misses in stable scores, + // `score.MaximumStatistics.GetValueOrDefault(Great)` will be equal to the count of fruits *and* large droplets + // rather than just fruits (which was the intent). + // this is not fixable without introducing an extra legacy score attribute dedicated for catch, + // and this is a ballpark conversion process anyway, so attempt to trudge on. + int fruitTinyScaleDivisor = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + score.MaximumStatistics.GetValueOrDefault(HitResult.Great); + double fruitTinyScale = fruitTinyScaleDivisor == 0 + ? 0 + : (double)score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) / fruitTinyScaleDivisor; + + const int max_tiny_droplets_portion = 400000; + + double comboPortion = 1000000 - max_tiny_droplets_portion + max_tiny_droplets_portion * (1 - fruitTinyScale); + double dropletsPortion = max_tiny_droplets_portion * fruitTinyScale; + double dropletsHit = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) == 0 + ? 0 + : (double)score.Statistics.GetValueOrDefault(HitResult.SmallTickHit) / score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit); + convertedTotalScore = (long)Math.Round(( - 600000 * comboProportion - + 400000 * score.Accuracy + comboPortion * estimateComboProportionForCatch(attributes.MaxCombo, score.MaxCombo, score.Statistics.GetValueOrDefault(HitResult.Miss)) + + dropletsPortion * dropletsHit + bonusProportion) * modMultiplier); break; @@ -461,6 +482,94 @@ double lowerEstimateOfComboPortionInStandardisedScore return convertedTotalScore; } + /// + /// + /// For catch, the general method of calculating the combo proportion used for other rulesets is generally useless. + /// This is because in stable score V1, catch has quadratic score progression, + /// while in stable score V2, score progression is logarithmic up to 200 combo and then linear. + /// + /// + /// This means that applying the naive rescale method to scores with lots of short combos (think 10x 100-long combos on a 1000-object map) + /// by linearly rescaling the combo portion as given by score V1 leads to horribly underestimating it. + /// Therefore this method attempts to counteract this by calculating the best case estimate for the combo proportion that takes all of the above into account. + /// + /// + /// The general idea is that aside from the which the player is known to have hit, + /// the remaining misses are evenly distributed across the rest of the objects that give combo. + /// This is therefore a worst-case estimate. + /// + /// + private static double estimateComboProportionForCatch(int beatmapMaxCombo, int scoreMaxCombo, int scoreMissCount) + { + if (beatmapMaxCombo == 0) + return 1; + + if (scoreMaxCombo == 0) + return 0; + + if (beatmapMaxCombo == scoreMaxCombo) + return 1; + + double estimatedBestCaseTotal = estimateBestCaseComboTotal(beatmapMaxCombo); + + int remainingCombo = beatmapMaxCombo - (scoreMaxCombo + scoreMissCount); + double totalDroppedScore = 0; + + int assumedLengthOfRemainingCombos = (int)Math.Floor((double)remainingCombo / scoreMissCount); + + if (assumedLengthOfRemainingCombos > 0) + { + while (remainingCombo > 0) + { + int comboLength = Math.Min(assumedLengthOfRemainingCombos, remainingCombo); + + remainingCombo -= comboLength; + totalDroppedScore += estimateDroppedComboScoreAfterMiss(comboLength); + } + } + else + { + // there are so many misses that attempting to evenly divide remaining combo results in 0 length per combo, + // i.e. all remaining judgements are combo breaks. + // in that case, presume every single remaining object is a miss and did not give any combo score. + totalDroppedScore = estimatedBestCaseTotal - estimateBestCaseComboTotal(scoreMaxCombo); + } + + return estimatedBestCaseTotal == 0 + ? 1 + : 1 - Math.Clamp(totalDroppedScore / estimatedBestCaseTotal, 0, 1); + + double estimateBestCaseComboTotal(int maxCombo) + { + if (maxCombo == 0) + return 1; + + double estimatedTotal = 0.5 * Math.Min(maxCombo, 2); + + if (maxCombo <= 2) + return estimatedTotal; + + // int_2^x log_4(t) dt + estimatedTotal += (Math.Min(maxCombo, 200) * (Math.Log(Math.Min(maxCombo, 200)) - 1) + 2 - Math.Log(4)) / Math.Log(4); + + if (maxCombo <= 200) + return estimatedTotal; + + estimatedTotal += (maxCombo - 200) * Math.Log(200) / Math.Log(4); + return estimatedTotal; + } + + double estimateDroppedComboScoreAfterMiss(int lengthOfComboAfterMiss) + { + if (lengthOfComboAfterMiss >= 200) + lengthOfComboAfterMiss = 200; + + // int_0^x (log_4(200) - log_4(t)) dt + // note that this is an pessimistic estimate, i.e. it may subtract too much if the miss happened before reaching 200 combo + return lengthOfComboAfterMiss * (1 + Math.Log(200) - Math.Log(lengthOfComboAfterMiss)) / Math.Log(4); + } + } + public static double ComputeAccuracy(ScoreInfo scoreInfo) { Ruleset ruleset = scoreInfo.Ruleset.CreateInstance(); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index cf0a7bd54f00..495edaf49c46 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -36,9 +36,10 @@ public class LegacyScoreEncoder /// 30000007: Adjust osu!mania combo and accuracy portions and judgement scoring values. Reconvert all scores. /// 30000008: Add accuracy conversion. Reconvert all scores. /// 30000009: Fix edge cases in conversion for scores which have 0.0x mod multiplier on stable. Reconvert all scores. + /// 30000010: Re-do catch scoring to mirror stable Score V2 as closely as feasible. Reconvert all scores. /// /// - public const int LATEST_VERSION = 30000009; + public const int LATEST_VERSION = 30000010; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. From e2769dbda14b21e550c3bafa9446af1e406e2966 Mon Sep 17 00:00:00 2001 From: Zachary Date: Sat, 6 Jan 2024 19:29:41 +1000 Subject: [PATCH 13/70] Attempt at creating a test. --- .../TestSceneIntroMusicActionHandling.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs new file mode 100644 index 000000000000..cc2b16a842f2 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs @@ -0,0 +1,47 @@ +// 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.Testing; +using osu.Game.Input.Bindings; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + public partial class TestSceneIntroMusicActionHandling : OsuTestScene + { + private OsuGameTestScene.TestOsuGame? game; + + private GlobalActionContainer globalActionContainer => game.ChildrenOfType().First(); + + [Test] + public void TestPauseDuringIntro() + { + AddStep("Create new game instance", () => + { + if (game?.Parent != null) + Remove(game, true); + + RecycleLocalStorage(false); + + AddGame(game = new OsuGameTestScene.TestOsuGame(LocalStorage, API)); + }); + + AddUntilStep("Wait for load", () => game?.IsLoaded ?? false); + AddUntilStep("Wait for intro", () => game?.ScreenStack.CurrentScreen is IntroScreen); + AddUntilStep("Wait for music", () => game?.MusicController.IsPlaying == true); + + // Check that pause dosesn't work during intro sequence. + AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); + AddAssert("Still playing before menu", () => game?.MusicController.IsPlaying == true); + AddUntilStep("Wait for main menu", () => game?.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); + + // Check that toggling after intro still works. + AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); + AddUntilStep("Music paused", () => game?.MusicController.IsPlaying == false && game?.MusicController.UserPauseRequested == true); + AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); + AddUntilStep("Music resumed", () => game?.MusicController.IsPlaying == true && game?.MusicController.UserPauseRequested == false); + } + } +} From 14a43375a70104094f7a9d23cf091c18d1223ad3 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 6 Jan 2024 20:25:03 +0900 Subject: [PATCH 14/70] Fix overall ranking text overlapping at some aspect ratios Can't confirm on the actual ranking screen due to stuff not working. Maybe it'll work tomorrow. Closes https://github.com/ppy/osu/issues/26341. --- osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs index 73b9897096fe..762be618535f 100644 --- a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs @@ -37,7 +37,6 @@ protected override ICollection CreateStatisticItems(ScoreInfo new RelativeSizeAxes = Axes.X, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Width = 0.5f, StatisticsUpdate = { BindTarget = StatisticsUpdate } })).ToArray(); } From d3710f0bfd0e8fb457ffbde1fb14dce853ad4f14 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 6 Jan 2024 20:44:07 +0900 Subject: [PATCH 15/70] Remove scores from song select leaderboard when leaving the screen --- osu.Game/Online/Leaderboards/Leaderboard.cs | 13 ++++++++++--- osu.Game/Screens/Select/PlaySongSelect.cs | 8 ++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 93aa0b95a7c1..67f2590ad8ed 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -152,6 +152,15 @@ protected override void LoadComplete() /// public void RefetchScores() => Scheduler.AddOnce(refetchScores); + /// + /// Clear all scores from the display. + /// + public void ClearScores() + { + cancelPendingWork(); + SetScores(null); + } + /// /// Call when a retrieval or display failure happened to show a relevant message to the user. /// @@ -220,9 +229,7 @@ private void refetchScores() { Debug.Assert(ThreadSafety.IsUpdateThread); - cancelPendingWork(); - - SetScores(null); + ClearScores(); setState(LeaderboardState.Retrieving); currentFetchCancellationSource = new CancellationTokenSource(); diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 7b7b8857f373..4951504ff55d 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -146,6 +146,14 @@ Player createPlayer() } } + public override void OnSuspending(ScreenTransitionEvent e) + { + // Scores will be refreshed on arriving at this screen. + // Clear them to avoid animation overload on returning to song select. + playBeatmapDetailArea.Leaderboard.ClearScores(); + base.OnSuspending(e); + } + public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); From b809d4c068f77558e30e80e0a9a8846cd41af5c9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 02:14:03 +0900 Subject: [PATCH 16/70] Remove delegate overhead from argon health display's animation updates --- osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs | 16 ++++++++++++---- osu.Game/Screens/Play/HUD/HealthDisplay.cs | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 8acc43c091da..7721f9c0c064 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -68,11 +69,11 @@ public double GlowBarValue get => glowBarValue; set { - if (glowBarValue == value) + if (Precision.AlmostEquals(glowBarValue, value, 0.0001)) return; glowBarValue = value; - Scheduler.AddOnce(updatePathVertices); + pathVerticesCache.Invalidate(); } } @@ -83,11 +84,11 @@ public double HealthBarValue get => healthBarValue; set { - if (healthBarValue == value) + if (Precision.AlmostEquals(healthBarValue, value, 0.0001)) return; healthBarValue = value; - Scheduler.AddOnce(updatePathVertices); + pathVerticesCache.Invalidate(); } } @@ -100,6 +101,8 @@ public double HealthBarValue private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + private readonly Cached pathVerticesCache = new Cached(); + public ArgonHealthDisplay() { AddLayout(drawSizeLayout); @@ -208,6 +211,9 @@ protected override void Update() drawSizeLayout.Validate(); } + if (!pathVerticesCache.IsValid) + updatePathVertices(); + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); } @@ -346,6 +352,8 @@ private void updatePathVertices() mainBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); mainBar.Position = healthBarVertices[0]; + + pathVerticesCache.Validate(); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 774703682670..54aba6b8da84 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -30,7 +30,7 @@ public abstract partial class HealthDisplay : CompositeDrawable public Bindable Current { get; } = new BindableDouble { MinValue = 0, - MaxValue = 1 + MaxValue = 1, }; private BindableNumber health = null!; From 9d9e6fcfdbc3c21faa7e637375ac4a9e4cdf0c51 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 03:22:19 +0900 Subject: [PATCH 17/70] Remove LINQ calls in hot paths --- osu.Game/Skinning/SkinnableSound.cs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f866a4f8ec91..f153f4f8d321 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -194,9 +194,33 @@ private void updateSamples() /// /// Whether any samples are currently playing. /// - public bool IsPlaying => samplesContainer.Any(s => s.Playing); + public bool IsPlaying + { + get + { + foreach (PoolableSkinnableSample s in samplesContainer) + { + if (s.Playing) + return true; + } + + return false; + } + } - public bool IsPlayed => samplesContainer.Any(s => s.Played); + public bool IsPlayed + { + get + { + foreach (PoolableSkinnableSample s in samplesContainer) + { + if (s.Played) + return true; + } + + return false; + } + } public IBindable AggregateVolume => samplesContainer.AggregateVolume; From 35eff639cb936188f73c34dc71e67ea4d93a061d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 03:25:00 +0900 Subject: [PATCH 18/70] Remove unnecessary second iteration over `NestedHitObjects` --- .../Objects/Drawables/DrawableSlider.cs | 7 ++----- .../Objects/Drawables/DrawableSliderRepeat.cs | 2 +- .../Objects/Drawables/ITrackSnaking.cs | 15 --------------- 3 files changed, 3 insertions(+), 21 deletions(-) delete mode 100644 osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index b306fd38c19e..0f8c9a4d3653 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -246,11 +246,8 @@ protected override void Update() Ball.UpdateProgress(completionProgress); SliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0); - foreach (DrawableHitObject hitObject in NestedHitObjects) - { - if (hitObject is ITrackSnaking s) - s.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0)); - } + foreach (DrawableSliderRepeat repeat in repeatContainer) + repeat.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0)); Size = SliderBody?.Size ?? Vector2.Zero; OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index c6d4f7c4cada..32395655289e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking + public partial class DrawableSliderRepeat : DrawableOsuHitObject { public new SliderRepeat HitObject => (SliderRepeat)base.HitObject; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs deleted file mode 100644 index cae2a7c36d10..000000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs +++ /dev/null @@ -1,15 +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 osuTK; - -namespace osu.Game.Rulesets.Osu.Objects.Drawables -{ - /// - /// A component which tracks the current end snaking position of a slider. - /// - public interface ITrackSnaking - { - void UpdateSnakingPosition(Vector2 start, Vector2 end); - } -} From 5cc4a586acf40fd3d9be3425f875e54406a38c4d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 5 Jan 2024 03:26:58 +0900 Subject: [PATCH 19/70] Avoid iteration over `NestedHitObjects` in silder's `Update` unless necessary --- .../Objects/Drawables/DrawableSlider.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 0f8c9a4d3653..c5194025c15c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -36,6 +36,8 @@ public partial class DrawableSlider : DrawableOsuHitObject private ShakeContainer shakeContainer; + private Vector2? childAnchorPosition; + protected override IEnumerable DimmablePieces => new Drawable[] { HeadCircle, @@ -254,10 +256,15 @@ protected override void Update() if (DrawSize != Vector2.Zero) { - var childAnchorPosition = Vector2.Divide(OriginPosition, DrawSize); - foreach (var obj in NestedHitObjects) - obj.RelativeAnchorPosition = childAnchorPosition; - Ball.RelativeAnchorPosition = childAnchorPosition; + Vector2 pos = Vector2.Divide(OriginPosition, DrawSize); + + if (pos != childAnchorPosition) + { + childAnchorPosition = pos; + foreach (var obj in NestedHitObjects) + obj.RelativeAnchorPosition = pos; + Ball.RelativeAnchorPosition = pos; + } } } From 16ea7f9b7787462aa1506bac4ecb0c57090c630d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 7 Jan 2024 13:10:50 +0900 Subject: [PATCH 20/70] Avoid completely unnecessary string allocations in `ArgonCounterTextComponent` --- .../Screens/Play/HUD/ArgonCounterTextComponent.cs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index 266dfb3301f3..9d364acd5950 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -35,14 +34,7 @@ public partial class ArgonCounterTextComponent : CompositeDrawable, IHasText public LocalisableString Text { get => textPart.Text; - set - { - int remainingCount = RequiredDisplayDigits.Value - value.ToString().Count(char.IsDigit); - string remainingText = remainingCount > 0 ? new string('#', remainingCount) : string.Empty; - - wireframesPart.Text = remainingText + value; - textPart.Text = value; - } + set => textPart.Text = value; } public ArgonCounterTextComponent(Anchor anchor, LocalisableString? label = null) @@ -83,6 +75,8 @@ public ArgonCounterTextComponent(Anchor anchor, LocalisableString? label = null) } } }; + + RequiredDisplayDigits.BindValueChanged(digits => wireframesPart.Text = new string('#', digits.NewValue)); } private string textLookup(char c) From dc31c66f6229445dd0ebd0d6a6a52c7241da59f0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 7 Jan 2024 19:49:42 +0900 Subject: [PATCH 21/70] Return null on font lookup failure instead of asserting Fallback weirdness. --- osu.Game/Skinning/LegacySpriteText.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 81db5fdf3635..581e7534e474 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -74,7 +73,8 @@ public LegacyGlyphStore(string fontName, ISkin skin, Vector2? maxSize) public ITexturedCharacterGlyph? Get(string? fontName, char character) { // We only service one font. - Debug.Assert(fontName == this.fontName); + if (fontName != this.fontName) + return null; if (cache.TryGetValue(character, out var cached)) return cached; From 962c8ba4acfc8920633f99c022824d4d58564ef9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 7 Jan 2024 20:55:28 +0900 Subject: [PATCH 22/70] Reset child anchor position cache on hitobject position change --- osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index c5194025c15c..6cc8b8e935a1 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -121,7 +121,11 @@ private void load() } }); - PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + PositionBindable.BindValueChanged(_ => + { + Position = HitObject.StackedPosition; + childAnchorPosition = null; + }); StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); ScaleBindable.BindValueChanged(scale => Ball.Scale = new Vector2(scale.NewValue)); From 7b663a27bd4d37334e428f1afc3179bc8f9da7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 10:47:22 +0100 Subject: [PATCH 23/70] Fix score conversion incorrectly assuming zero combo score in certain cases --- .../StandardisedScoreMigrationTools.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 9cfb9ea95756..24fe147593a7 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -311,13 +311,22 @@ private static long convertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapCo long maximumLegacyBonusScore = attributes.BonusScore; double legacyAccScore = maximumLegacyAccuracyScore * score.Accuracy; - // We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio. - // Note that `maximumLegacyComboScore + maximumLegacyBonusScore` can actually be 0 - // when playing a beatmap with no bonus objects, with mods that have a 0.0x multiplier on stable (relax/autopilot). - // In such cases, just assume 0. - double comboProportion = maximumLegacyComboScore + maximumLegacyBonusScore > 0 - ? Math.Max((double)score.LegacyTotalScore - legacyAccScore, 0) / (maximumLegacyComboScore + maximumLegacyBonusScore) - : 0; + + double comboProportion; + + if (maximumLegacyComboScore + maximumLegacyBonusScore > 0) + { + // We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio. + comboProportion = Math.Max((double)score.LegacyTotalScore - legacyAccScore, 0) / (maximumLegacyComboScore + maximumLegacyBonusScore); + } + else + { + // Two possible causes: + // the beatmap has no bonus objects *AND* + // either the active mods have a zero mod multiplier, in which case assume 0, + // or the *beatmap* has a zero `difficultyPeppyStars` (or just no combo-giving objects), in which case assume 1. + comboProportion = legacyModMultiplier == 0 ? 0 : 1; + } // We assume the bonus proportion only makes up the rest of the score that exceeds maximumLegacyBaseScore. long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore; From 50eba9ebdb92790c10fda1904219525029abe0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 12:52:14 +0100 Subject: [PATCH 24/70] Reduce code duplication in test --- .../TestSceneIntroMusicActionHandling.cs | 32 +++++++------------ osu.Game/Tests/Visual/OsuGameTestScene.cs | 8 +++-- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs index cc2b16a842f2..00c14dc79716 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs @@ -9,39 +9,31 @@ namespace osu.Game.Tests.Visual.Menus { - public partial class TestSceneIntroMusicActionHandling : OsuTestScene + public partial class TestSceneIntroMusicActionHandling : OsuGameTestScene { - private OsuGameTestScene.TestOsuGame? game; + private GlobalActionContainer globalActionContainer => Game.ChildrenOfType().First(); - private GlobalActionContainer globalActionContainer => game.ChildrenOfType().First(); + public override void SetUpSteps() + { + CreateNewGame(); + // we do not want to progress to main menu immediately, hence the override and lack of `ConfirmAtMainMenu()` call here. + } [Test] public void TestPauseDuringIntro() { - AddStep("Create new game instance", () => - { - if (game?.Parent != null) - Remove(game, true); - - RecycleLocalStorage(false); - - AddGame(game = new OsuGameTestScene.TestOsuGame(LocalStorage, API)); - }); - - AddUntilStep("Wait for load", () => game?.IsLoaded ?? false); - AddUntilStep("Wait for intro", () => game?.ScreenStack.CurrentScreen is IntroScreen); - AddUntilStep("Wait for music", () => game?.MusicController.IsPlaying == true); + AddUntilStep("Wait for music", () => Game?.MusicController.IsPlaying == true); // Check that pause dosesn't work during intro sequence. AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); - AddAssert("Still playing before menu", () => game?.MusicController.IsPlaying == true); - AddUntilStep("Wait for main menu", () => game?.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); + AddAssert("Still playing before menu", () => Game?.MusicController.IsPlaying == true); + AddUntilStep("Wait for main menu", () => Game?.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); // Check that toggling after intro still works. AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); - AddUntilStep("Music paused", () => game?.MusicController.IsPlaying == false && game?.MusicController.UserPauseRequested == true); + AddUntilStep("Music paused", () => Game?.MusicController.IsPlaying == false && Game?.MusicController.UserPauseRequested == true); AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); - AddUntilStep("Music resumed", () => game?.MusicController.IsPlaying == true && game?.MusicController.UserPauseRequested == false); + AddUntilStep("Music resumed", () => Game?.MusicController.IsPlaying == true && Game?.MusicController.UserPauseRequested == false); } } } diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 94be4a375d1a..947305439ee0 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -58,6 +58,12 @@ private void load() [SetUpSteps] public virtual void SetUpSteps() + { + CreateNewGame(); + ConfirmAtMainMenu(); + } + + protected void CreateNewGame() { AddStep("Create new game instance", () => { @@ -71,8 +77,6 @@ public virtual void SetUpSteps() AddUntilStep("Wait for load", () => Game.IsLoaded); AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); - - ConfirmAtMainMenu(); } [TearDownSteps] From b869be2f463b41781bfaefd2926c54925a33ac03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 12:52:23 +0100 Subject: [PATCH 25/70] Fix typo --- .../Visual/Menus/TestSceneIntroMusicActionHandling.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs index 00c14dc79716..01aeaff1db81 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroMusicActionHandling.cs @@ -24,7 +24,7 @@ public void TestPauseDuringIntro() { AddUntilStep("Wait for music", () => Game?.MusicController.IsPlaying == true); - // Check that pause dosesn't work during intro sequence. + // Check that pause doesn't work during intro sequence. AddStep("Toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); AddAssert("Still playing before menu", () => Game?.MusicController.IsPlaying == true); AddUntilStep("Wait for main menu", () => Game?.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); From b6ce57b777983af3808b9736d86d1aba4b6faf80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 12:54:16 +0100 Subject: [PATCH 26/70] Use override that was intended to steer global track control rather than local sets --- osu.Game/Screens/Menu/IntroScreen.cs | 4 ++-- osu.Game/Screens/Menu/MainMenu.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index a81e24ee88cd..ac7dffc241a3 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -95,6 +95,8 @@ public abstract partial class IntroScreen : StartupScreen Colour = Color4.Black }; + public override bool? AllowGlobalTrackControl => false; + protected IntroScreen([CanBeNull] Func createNextScreen = null) { this.createNextScreen = createNextScreen; @@ -109,8 +111,6 @@ private void load(OsuConfigManager config, Framework.Game game, RealmAccess real // prevent user from changing beatmap while the intro is still running. beatmap = Beatmap.BeginLease(false); - musicController.AllowTrackControl.Value = false; - MenuVoice = config.GetBindable(OsuSetting.MenuVoice); MenuMusic = config.GetBindable(OsuSetting.MenuMusic); diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index ffb68367c2f2..b264341cc5d8 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -49,6 +49,8 @@ public partial class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHan public override bool AllowExternalScreenChange => true; + public override bool? AllowGlobalTrackControl => true; + private Screen songSelect; private MenuSideFlashes sideFlashes; @@ -241,8 +243,6 @@ public override void OnEntering(ScreenTransitionEvent e) { var track = musicController.CurrentTrack; - musicController.AllowTrackControl.Value = true; - // presume the track is the current beatmap's track. not sure how correct this assumption is but it has worked until now. if (!track.IsRunning) { From 8c82bb006cc5f842f943c513d70ab33d9d7234f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 09:43:53 +0100 Subject: [PATCH 27/70] Fix mania score conversion using score V1 accuracy Partially addresses https://github.com/ppy/osu/discussions/26416 As pointed out in the discussion thread above, the total score conversion process for mania was using accuracy directly from the replay. In mania accuracy is calculated differently in score V1 than in score V2, which meant that scores coming from stable were treated more favourably (due to weighting GREAT and PERFECT equally). To fix, recompute accuracy locally and use that for the accuracy portion. Note that this will still not be (and cannot be made) 100% accurate, as in stable score V2, as well as in lazer, hold notes are *two* judgements, not one as in stable score V1, meaning that full and correct score statistics are not available without playing back the replay. The effects of the change can be previewed on the following spreadsheet: https://docs.google.com/spreadsheets/d/1wxD4UwLjwcr7n9y5Yq7EN0lgiLBN93kpd4gBnAlG-E0/edit#gid=1711190356 Top 5 changed scores with replays: | score | master | this PR | replay | | :------------------------------------------------------------------------------------------------------------------------------- | ------: | ------: | ------: | | [Outlasted on Uwa!! So Holiday by toby fox [[4K] easy] (0.71\*)](https://osu.ppy.sh/scores/mania/460404716) | 935,917 | 927,269 | 920,579 | | [ag0 on Emotional Uplifting Orchestral by bradbreeck [[4K] Rocket's Normal] (0.76\*)](https://osu.ppy.sh/scores/mania/453133066) | 921,636 | 913,535 | 875,549 | | [rlarkgus on Zen Zen Zense by Gom (HoneyWorks) [[5K] Normal] (1.68\*)](https://osu.ppy.sh/scores/mania/458368312) | 934,340 | 926,787 | 918,855 | | [YuJJun on Harumachi Clover by R3 Music Box [4K Catastrophe] (1.80\*)](https://osu.ppy.sh/scores/mania/548215786) | 918,606 | 911,111 | 885,454 | | [Fritte on 45-byou by respon feat. Hatsune Miku & Megpoid [[5K] Normal] (1.52\*)](https://osu.ppy.sh/scores/mania/516079410) | 900,024 | 892,569 | 907,456 | --- osu.Game/Database/StandardisedScoreMigrationTools.cs | 7 ++++++- osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 9cfb9ea95756..1ef73550273a 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -444,9 +444,14 @@ double lowerEstimateOfComboPortionInStandardisedScore break; case 3: + // in the mania case accuracy actually changes between score V1 and score V2 / standardised + // (PERFECT weighting changes from 300 to 305), + // so for better accuracy recompute accuracy locally based on hit statistics and use that instead, + double scoreV2Accuracy = ComputeAccuracy(score); + convertedTotalScore = (long)Math.Round(( 850000 * comboProportion - + 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy) + + 150000 * Math.Pow(scoreV2Accuracy, 2 + 2 * scoreV2Accuracy) + bonusProportion) * modMultiplier); break; diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index cf0a7bd54f00..95f2ee0552a0 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -36,9 +36,10 @@ public class LegacyScoreEncoder /// 30000007: Adjust osu!mania combo and accuracy portions and judgement scoring values. Reconvert all scores. /// 30000008: Add accuracy conversion. Reconvert all scores. /// 30000009: Fix edge cases in conversion for scores which have 0.0x mod multiplier on stable. Reconvert all scores. + /// 30000010: Fix mania score V1 conversion using score V1 accuracy rather than V2 accuracy. Reconvert all scores. /// /// - public const int LATEST_VERSION = 30000009; + public const int LATEST_VERSION = 30000010; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. From e77d203a24088174a47810a70112e2a4cda46fc7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 01:08:17 +0900 Subject: [PATCH 28/70] Refactor delayed load logic to hopefully read better --- .../Carousel/DrawableCarouselBeatmapSet.cs | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 61658526db2e..b70278c9bb41 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -199,27 +199,34 @@ private void loadContentIfRequired() Debug.Assert(Item != null); - if (loadCancellation == null && (timeSinceUnpool += Time.Elapsed) > timeUpdatingBeforeLoad) - { - loadCancellation = new CancellationTokenSource(); + // A load is already in progress if the cancellation token is non-null. + if (loadCancellation != null) + return; + + timeSinceUnpool += Time.Elapsed; + + // We only trigger a load after this set has been in an updating state for a set amount of time. + if (timeSinceUnpool <= timeUpdatingBeforeLoad) + return; + + loadCancellation = new CancellationTokenSource(); - LoadComponentsAsync(new CompositeDrawable[] + LoadComponentsAsync(new CompositeDrawable[] + { + new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) { - new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) - { - RelativeSizeAxes = Axes.Both, - }, - new SetPanelContent((CarouselBeatmapSet)Item) - { - Depth = float.MinValue, - RelativeSizeAxes = Axes.Both, - } - }, drawables => + RelativeSizeAxes = Axes.Both, + }, + new SetPanelContent((CarouselBeatmapSet)Item) { - Header.AddRange(drawables); - drawables.ForEach(d => d.FadeInFromZero(150)); - }, loadCancellation.Token); - } + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + } + }, drawables => + { + Header.AddRange(drawables); + drawables.ForEach(d => d.FadeInFromZero(150)); + }, loadCancellation.Token); } private void updateBeatmapYPositions() From 51bd32bf7e94e74967d6852ead980b71bed6a8e6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 01:08:47 +0900 Subject: [PATCH 29/70] Restore comment regarding usage of `MinBy` --- osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index b70278c9bb41..369db37e6319 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -213,6 +213,7 @@ private void loadContentIfRequired() LoadComponentsAsync(new CompositeDrawable[] { + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) { RelativeSizeAxes = Axes.Both, From c4ac53002c04567f29b9bd0efe9a7ac16e72a4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 19:49:22 +0100 Subject: [PATCH 30/70] Remove loop in combo score loss estimation calculation --- osu.Game/Database/StandardisedScoreMigrationTools.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index f029d85aed0c..07b4fe7f40fd 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -519,13 +519,13 @@ private static double estimateComboProportionForCatch(int beatmapMaxCombo, int s if (assumedLengthOfRemainingCombos > 0) { - while (remainingCombo > 0) - { - int comboLength = Math.Min(assumedLengthOfRemainingCombos, remainingCombo); + int assumedCombosCount = (int)Math.Floor((double)remainingCombo / assumedLengthOfRemainingCombos); + totalDroppedScore += assumedCombosCount * estimateDroppedComboScoreAfterMiss(assumedLengthOfRemainingCombos); - remainingCombo -= comboLength; - totalDroppedScore += estimateDroppedComboScoreAfterMiss(comboLength); - } + remainingCombo -= assumedCombosCount * assumedLengthOfRemainingCombos; + + if (remainingCombo > 0) + totalDroppedScore += estimateDroppedComboScoreAfterMiss(remainingCombo); } else { From 8a87301c55262f888017cb2ce5ed23d04429ab05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 21:33:25 +0100 Subject: [PATCH 31/70] Add test for crashing scenario --- .../Navigation/TestSceneScreenNavigation.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index a0069f55c7fe..8cb993eff266 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -938,6 +938,35 @@ public void TestTouchScreenDetectionInGame() AddUntilStep("touch device mod still active", () => Game.SelectedMods.Value, () => Has.One.InstanceOf()); } + [Test] + public void TestExitSongSelectAndImmediatelyClickLogo() + { + Screens.Select.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("press escape and then click logo immediately", () => + { + InputManager.Key(Key.Escape); + clickLogoWhenNotCurrent(); + }); + + void clickLogoWhenNotCurrent() + { + if (songSelect.IsCurrentScreen()) + Scheduler.AddOnce(clickLogoWhenNotCurrent); + else + { + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + } + } + } + private Func playToResults() { var player = playToCompletion(); From 58db39ec3206a34e1b8a0cbdbe196059dd4b8306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 21:37:25 +0100 Subject: [PATCH 32/70] Fix crash when clicking osu! logo in song select immediately after exiting Closes https://github.com/ppy/osu/issues/26415. The crash report with incomplete log was backwards, the exit comes first. Sentry events and the reproducing test in 8a87301c55262f888017cb2ce5ed23d04429ab05 confirm this. --- osu.Game/Screens/Select/PlaySongSelect.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 4951504ff55d..3cf8de526778 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -92,6 +92,9 @@ protected override bool OnStart() { if (playerLoader != null) return false; + if (!this.IsCurrentScreen()) + return false; + modsAtGameplayStart = Mods.Value; // Ctrl+Enter should start map with autoplay enabled. From 67df7b33fb6c355ad41382fae8323c5c9e3d0c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 21:51:01 +0100 Subject: [PATCH 33/70] Add failing test coverage for not attempting to upgrade custom ruleset scores --- .../BackgroundDataStoreProcessorTests.cs | 30 +++++++++++++++++++ osu.Game/BackgroundDataStoreProcessor.cs | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs index 43ce7200d28f..9dbfde7bcef4 100644 --- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -182,9 +183,38 @@ public void TestScoreUpgradeFailed() AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000002)); } + [Test] + public void TestCustomRulesetScoreNotSubjectToUpgrades([Values] bool available) + { + RulesetInfo rulesetInfo = null!; + ScoreInfo scoreInfo = null!; + TestBackgroundDataStoreProcessor processor = null!; + + AddStep("Add unavailable ruleset", () => Realm.Write(r => r.Add(rulesetInfo = new RulesetInfo + { + ShortName = Guid.NewGuid().ToString(), + Available = available + }))); + + AddStep("Add score for unavailable ruleset", () => Realm.Write(r => r.Add(scoreInfo = new ScoreInfo( + ruleset: rulesetInfo, + beatmap: r.All().First()) + { + TotalScoreVersion = 30000001 + }))); + + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); + + AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False); + AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000001)); + } + public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor { protected override int TimeToSleepDuringGameplay => 10; + + public bool Completed => ProcessingTask.IsCompleted; } } } diff --git a/osu.Game/BackgroundDataStoreProcessor.cs b/osu.Game/BackgroundDataStoreProcessor.cs index a748a7422a91..969062be5770 100644 --- a/osu.Game/BackgroundDataStoreProcessor.cs +++ b/osu.Game/BackgroundDataStoreProcessor.cs @@ -28,6 +28,8 @@ namespace osu.Game /// public partial class BackgroundDataStoreProcessor : Component { + protected Task ProcessingTask = null!; + [Resolved] private RulesetStore rulesetStore { get; set; } = null!; @@ -61,7 +63,7 @@ protected override void LoadComplete() { base.LoadComplete(); - Task.Factory.StartNew(() => + ProcessingTask = Task.Factory.StartNew(() => { Logger.Log("Beginning background data store processing.."); From 388f6599e0264d640d4da1fda0e27441212ca389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 21:54:10 +0100 Subject: [PATCH 34/70] Add failing test for not attempting to upgrade non-legacy scores This was a source of confusion for users previously, wondering why their non-legacy (non-stable) scores weren't being converted in line with new scoring changes, when it was never actually our intention to support anything of the sort. --- .../BackgroundDataStoreProcessorTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs index 9dbfde7bcef4..8b066f860fde 100644 --- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs @@ -210,6 +210,31 @@ public void TestCustomRulesetScoreNotSubjectToUpgrades([Values] bool available) AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000001)); } + [Test] + public void TestNonLegacyScoreNotSubjectToUpgrades() + { + ScoreInfo scoreInfo = null!; + TestBackgroundDataStoreProcessor processor = null!; + + AddStep("Add score which requires upgrade (and has beatmap)", () => + { + Realm.Write(r => + { + r.Add(scoreInfo = new ScoreInfo(ruleset: r.All().First(), beatmap: r.All().First()) + { + TotalScoreVersion = 30000005, + LegacyTotalScore = 123456, + }); + }); + }); + + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); + + AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False); + AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000005)); + } + public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor { protected override int TimeToSleepDuringGameplay => 10; From aa83b84bb225781adccc15f4276a74901ed03397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 22:34:41 +0100 Subject: [PATCH 35/70] Fix Cinema mod being compatible with mods that can force failure Addresses https://github.com/ppy/osu/pull/26080#issuecomment-1868833214. --- osu.Game/Rulesets/Mods/ModCinema.cs | 2 +- osu.Game/Rulesets/Mods/ModFailCondition.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index dbb37e0af6b0..7c88a8a5881b 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -30,7 +30,7 @@ public class ModCinema : ModAutoplay, IApplicableToHUD, IApplicableToPlayer, IAp public override IconUsage? Icon => OsuIcon.ModCinema; public override LocalisableString Description => "Watch the video without visual distractions."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModAutoplay), typeof(ModNoFail) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }).ToArray(); public void ApplyToHUD(HUDOverlay overlay) { diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs index 471c3bfe8d30..0b229766c14f 100644 --- a/osu.Game/Rulesets/Mods/ModFailCondition.cs +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModFailCondition : Mod, IApplicableToHealthProcessor, IApplicableFailOverride { - public override Type[] IncompatibleMods => new[] { typeof(ModNoFail) }; + public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModCinema) }; [SettingSource("Restart on fail", "Automatically restarts when failed.")] public BindableBool Restart { get; } = new BindableBool(); From 4f7dcb3a5022cb533cc467d974a7f382d1cf285e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 8 Jan 2024 22:07:33 +0100 Subject: [PATCH 36/70] Do not attempt to recalculate non-legacy scores or scores set on custom rulesets Addresses discussions such as https://github.com/ppy/osu/discussions/26407 or https://github.com/ppy/osu/discussions/25914 wherein: - the game would attempt to convert scores for custom rulesets, which makes no sense, especially so when they're not there, - the game would also "recalculate" lazer scores, but that was never the intention or was never supported; the game would just increment the score version on those but still include them in the converted tally. --- osu.Game/BackgroundDataStoreProcessor.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/osu.Game/BackgroundDataStoreProcessor.cs b/osu.Game/BackgroundDataStoreProcessor.cs index 969062be5770..fc7db13d4105 100644 --- a/osu.Game/BackgroundDataStoreProcessor.cs +++ b/osu.Game/BackgroundDataStoreProcessor.cs @@ -13,6 +13,7 @@ using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -28,7 +29,7 @@ namespace osu.Game /// public partial class BackgroundDataStoreProcessor : Component { - protected Task ProcessingTask = null!; + protected Task ProcessingTask { get; private set; } = null!; [Resolved] private RulesetStore rulesetStore { get; set; } = null!; @@ -316,10 +317,17 @@ private void convertLegacyTotalScoreToStandardised() { Logger.Log("Querying for scores that need total score conversion..."); - HashSet scoreIds = realmAccess.Run(r => new HashSet(r.All() - .Where(s => !s.BackgroundReprocessingFailed && s.BeatmapInfo != null - && s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION) - .AsEnumerable().Select(s => s.ID))); + HashSet scoreIds = realmAccess.Run(r => new HashSet( + r.All() + .Where(s => !s.BackgroundReprocessingFailed + && s.BeatmapInfo != null + && s.IsLegacyScore + && s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION) + .AsEnumerable() + // must be done after materialisation, as realm doesn't want to support + // nested property predicates + .Where(s => s.Ruleset.IsLegacyRuleset()) + .Select(s => s.ID))); Logger.Log($"Found {scoreIds.Count} scores which require total score conversion."); From 58619f168458ead922914536a651951595f33875 Mon Sep 17 00:00:00 2001 From: Joseph Madamba Date: Mon, 8 Jan 2024 14:16:05 -0800 Subject: [PATCH 37/70] Fix wiki main page not displaying custom layout --- osu.Game/Overlays/WikiOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 440e451201cb..a8d9cdcdb2d4 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -137,7 +137,7 @@ private void onSuccess(APIWikiPage response) wikiData.Value = response; path.Value = response.Path; - if (response.Layout == index_path) + if (response.Layout == index_path.ToLowerInvariant()) { LoadDisplay(new WikiMainPage { From d6ba7a9c6eeab7c2c4889249df4f7280f28515d2 Mon Sep 17 00:00:00 2001 From: Joseph Madamba Date: Mon, 8 Jan 2024 14:24:56 -0800 Subject: [PATCH 38/70] Centralise `INDEX_PATH` to `WikiOverlay` --- osu.Game/Overlays/Wiki/WikiHeader.cs | 4 +--- osu.Game/Overlays/WikiOverlay.cs | 10 +++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 55be05ed7ad6..d64d6b934a29 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -17,8 +17,6 @@ namespace osu.Game.Overlays.Wiki { public partial class WikiHeader : BreadcrumbControlOverlayHeader { - private const string index_path = "Main_page"; - public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; public readonly Bindable WikiPageData = new Bindable(); @@ -45,7 +43,7 @@ private void onWikiPageChange(ValueChangedEvent e) TabControl.AddItem(IndexPageString); - if (e.NewValue.Path == index_path) + if (e.NewValue.Path == WikiOverlay.INDEX_PATH) { Current.Value = IndexPageString; return; diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index a8d9cdcdb2d4..3777e83cde52 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -19,11 +19,11 @@ namespace osu.Game.Overlays { public partial class WikiOverlay : OnlineOverlay { - private const string index_path = "Main_page"; + public const string INDEX_PATH = @"Main_page"; public string CurrentPath => path.Value; - private readonly Bindable path = new Bindable(index_path); + private readonly Bindable path = new Bindable(INDEX_PATH); private readonly Bindable wikiData = new Bindable(); @@ -43,7 +43,7 @@ public WikiOverlay() { } - public void ShowPage(string pagePath = index_path) + public void ShowPage(string pagePath = INDEX_PATH) { path.Value = pagePath.Trim('/'); Show(); @@ -137,7 +137,7 @@ private void onSuccess(APIWikiPage response) wikiData.Value = response; path.Value = response.Path; - if (response.Layout == index_path.ToLowerInvariant()) + if (response.Layout == INDEX_PATH.ToLowerInvariant()) { LoadDisplay(new WikiMainPage { @@ -161,7 +161,7 @@ private void onFail(string originalPath) path.Value = "error"; LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", - $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page](Main_page).")); + $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page]({INDEX_PATH}).")); } private void showParentPage() From b03813d3b40e0a279bb8ef6800cd587162adf0f2 Mon Sep 17 00:00:00 2001 From: Joseph Madamba Date: Mon, 8 Jan 2024 14:36:08 -0800 Subject: [PATCH 39/70] Update casing of hardcoded "Main_page" string in tests --- osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs | 4 ++-- osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs | 2 +- .../Visual/Online/TestSceneWikiMarkdownContainer.cs | 4 ++-- osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs index 4e71c5977e9d..40eda3f3dcfd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs @@ -24,8 +24,8 @@ public partial class TestSceneWikiHeader : OsuTestScene [Cached] private readonly Bindable wikiPageData = new Bindable(new APIWikiPage { - Title = "Main Page", - Path = "Main_Page", + Title = "Main page", + Path = "Main_page", }); private TestHeader header; diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs index 9967be73e8bf..7b4eadd46d9f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs @@ -36,7 +36,7 @@ public TestSceneWikiMainPage() }; } - // From https://osu.ppy.sh/api/v2/wiki/en/Main_Page + // From https://osu.ppy.sh/api/v2/wiki/en/Main_page private const string main_page_markdown = "---\nlayout: main_page\n---\n\n\n\n
\nWelcome to the osu! wiki, a project containing a wide range of osu! related information.\n
\n\n
\n
\n\n# Getting started\n\n[Welcome](/wiki/Welcome) • [Installation](/wiki/Installation) • [Registration](/wiki/Registration) • [Help Centre](/wiki/Help_Centre) • [FAQ](/wiki/FAQ)\n\n
\n
\n\n# Game client\n\n[Interface](/wiki/Interface) • [Options](/wiki/Options) • [Visual settings](/wiki/Visual_Settings) • [Shortcut key reference](/wiki/Shortcut_key_reference) • [Configuration file](/wiki/osu!_Program_Files/User_Configuration_File) • [Program files](/wiki/osu!_Program_Files)\n\n[File formats](/wiki/osu!_File_Formats): [.osz](/wiki/osu!_File_Formats/Osz_(file_format)) • [.osk](/wiki/osu!_File_Formats/Osk_(file_format)) • [.osr](/wiki/osu!_File_Formats/Osr_(file_format)) • [.osu](/wiki/osu!_File_Formats/Osu_(file_format)) • [.osb](/wiki/osu!_File_Formats/Osb_(file_format)) • [.db](/wiki/osu!_File_Formats/Db_(file_format))\n\n
\n
\n\n# Gameplay\n\n[Game modes](/wiki/Game_mode): [osu!](/wiki/Game_mode/osu!) • [osu!taiko](/wiki/Game_mode/osu!taiko) • [osu!catch](/wiki/Game_mode/osu!catch) • [osu!mania](/wiki/Game_mode/osu!mania)\n\n[Beatmap](/wiki/Beatmap) • [Hit object](/wiki/Hit_object) • [Mods](/wiki/Game_modifier) • [Score](/wiki/Score) • [Replay](/wiki/Replay) • [Multi](/wiki/Multi)\n\n
\n
\n\n# [Beatmap editor](/wiki/Beatmap_Editor)\n\nSections: [Compose](/wiki/Beatmap_Editor/Compose) • [Design](/wiki/Beatmap_Editor/Design) • [Timing](/wiki/Beatmap_Editor/Timing) • [Song setup](/wiki/Beatmap_Editor/Song_Setup)\n\nComponents: [AiMod](/wiki/Beatmap_Editor/AiMod) • [Beat snap divisor](/wiki/Beatmap_Editor/Beat_Snap_Divisor) • [Distance snap](/wiki/Beatmap_Editor/Distance_Snap) • [Menu](/wiki/Beatmap_Editor/Menu) • [SB load](/wiki/Beatmap_Editor/SB_Load) • [Timelines](/wiki/Beatmap_Editor/Timelines)\n\n[Beatmapping](/wiki/Beatmapping) • [Difficulty](/wiki/Beatmap/Difficulty) • [Mapping techniques](/wiki/Mapping_Techniques) • [Storyboarding](/wiki/Storyboarding)\n\n
\n
\n\n# Beatmap submission and ranking\n\n[Submission](/wiki/Submission) • [Modding](/wiki/Modding) • [Ranking procedure](/wiki/Beatmap_ranking_procedure) • [Mappers' Guild](/wiki/Mappers_Guild) • [Project Loved](/wiki/Project_Loved)\n\n[Ranking criteria](/wiki/Ranking_Criteria): [osu!](/wiki/Ranking_Criteria/osu!) • [osu!taiko](/wiki/Ranking_Criteria/osu!taiko) • [osu!catch](/wiki/Ranking_Criteria/osu!catch) • [osu!mania](/wiki/Ranking_Criteria/osu!mania)\n\n
\n
\n\n# Community\n\n[Tournaments](/wiki/Tournaments) • [Skinning](/wiki/Skinning) • [Projects](/wiki/Projects) • [Guides](/wiki/Guides) • [osu!dev Discord server](/wiki/osu!dev_Discord_server) • [How you can help](/wiki/How_You_Can_Help!) • [Glossary](/wiki/Glossary)\n\n
\n
\n\n# People\n\n[The Team](/wiki/People/The_Team): [Developers](/wiki/People/The_Team/Developers) • [Global Moderation Team](/wiki/People/The_Team/Global_Moderation_Team) • [Support Team](/wiki/People/The_Team/Support_Team) • [Nomination Assessment Team](/wiki/People/The_Team/Nomination_Assessment_Team) • [Beatmap Nominators](/wiki/People/The_Team/Beatmap_Nominators) • [osu! Alumni](/wiki/People/The_Team/osu!_Alumni) • [Project Loved Team](/wiki/People/The_Team/Project_Loved_Team)\n\nOrganisations: [osu! UCI](/wiki/Organisations/osu!_UCI)\n\n[Community Contributors](/wiki/People/Community_Contributors) • [Users with unique titles](/wiki/People/Users_with_unique_titles)\n\n
\n
\n\n# For developers\n\n[API](/wiki/osu!api) • [Bot account](/wiki/Bot_account) • [Brand identity guidelines](/wiki/Brand_identity_guidelines)\n\n
\n
\n\n# About the wiki\n\n[Sitemap](/wiki/Sitemap) • [Contribution guide](/wiki/osu!_wiki_Contribution_Guide) • [Article styling criteria](/wiki/Article_Styling_Criteria) • [News styling criteria](/wiki/News_Styling_Criteria)\n\n
\n
\n"; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index 6c87553971e8..890930560254 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -69,8 +69,8 @@ public void TestLink() { AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/"); - AddStep("set '/wiki/Main_Page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_Page)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_Page"); + AddStep("set '/wiki/Main_page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_page)"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_page"); AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)"); AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ"); diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 79c7e3a22ee3..352f100f3b27 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -107,12 +107,12 @@ private void setUpWikiResponse(APIWikiPage r, string? redirectionPath = null) }; }); - // From https://osu.ppy.sh/api/v2/wiki/en/Main_Page + // From https://osu.ppy.sh/api/v2/wiki/en/Main_page private APIWikiPage responseMainPage => new APIWikiPage { - Title = "Main Page", + Title = "Main page", Layout = "main_page", - Path = "Main_Page", + Path = "Main_page", Locale = "en", Subtitle = null, Markdown = From 7d57a668aba2032be4c000561be60f17674c4732 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 13:12:54 +0900 Subject: [PATCH 40/70] Use main page constant in more places --- osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs | 2 +- osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs index 40eda3f3dcfd..d259322d4a4e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs @@ -25,7 +25,7 @@ public partial class TestSceneWikiHeader : OsuTestScene private readonly Bindable wikiPageData = new Bindable(new APIWikiPage { Title = "Main page", - Path = "Main_page", + Path = WikiOverlay.INDEX_PATH, }); private TestHeader header; diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 352f100f3b27..8765d8485ac8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -11,6 +11,7 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Overlays.Wiki; namespace osu.Game.Tests.Visual.Online { @@ -111,8 +112,8 @@ private void setUpWikiResponse(APIWikiPage r, string? redirectionPath = null) private APIWikiPage responseMainPage => new APIWikiPage { Title = "Main page", - Layout = "main_page", - Path = "Main_page", + Layout = WikiOverlay.INDEX_PATH.ToLowerInvariant(), // custom classes are always lower snake. + Path = WikiOverlay.INDEX_PATH, Locale = "en", Subtitle = null, Markdown = From 172fe53099bf4e2e26e7acc9dc0dc7708b0ea39d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 13:13:32 +0900 Subject: [PATCH 41/70] Use better method of ignore case comparison --- osu.Game/Overlays/WikiOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 3777e83cde52..ffbc168fb7d3 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -137,7 +137,7 @@ private void onSuccess(APIWikiPage response) wikiData.Value = response; path.Value = response.Path; - if (response.Layout == INDEX_PATH.ToLowerInvariant()) + if (response.Layout.Equals(INDEX_PATH, StringComparison.OrdinalIgnoreCase)) { LoadDisplay(new WikiMainPage { From 3f5899dae041e66d8292acc757e25556a2172926 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 14:05:59 +0900 Subject: [PATCH 42/70] Fix incorrect implementation of wireframe digits --- .../Play/HUD/ArgonCounterTextComponent.cs | 1 - .../Screens/Play/HUD/ArgonScoreCounter.cs | 36 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index 9d364acd5950..f88874f87213 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; diff --git a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs index 005f7e36a7e5..f7ca218767b0 100644 --- a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -15,6 +16,8 @@ namespace osu.Game.Screens.Play.HUD { public partial class ArgonScoreCounter : GameplayScoreCounter, ISerialisableDrawable { + private ArgonScoreTextComponent scoreText = null!; + protected override double RollingDuration => 500; protected override Easing RollingEasing => Easing.OutQuint; @@ -33,13 +36,42 @@ public partial class ArgonScoreCounter : GameplayScoreCounter, ISerialisableDraw protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(); - protected override IHasText CreateText() => new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper()) + protected override IHasText CreateText() => scoreText = new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper()) { - RequiredDisplayDigits = { BindTarget = RequiredDisplayDigits }, WireframeOpacity = { BindTarget = WireframeOpacity }, ShowLabel = { BindTarget = ShowLabel }, }; + public ArgonScoreCounter() + { + RequiredDisplayDigits.BindValueChanged(_ => updateWireframe()); + } + + public override long DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + scoreText.RequiredDisplayDigits.Value = + Math.Max(RequiredDisplayDigits.Value, getDigitsRequiredForDisplayCount()); + } + + private int getDigitsRequiredForDisplayCount() + { + int digitsRequired = 1; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + private partial class ArgonScoreTextComponent : ArgonCounterTextComponent { public ArgonScoreTextComponent(Anchor anchor, LocalisableString? label = null) From 765d41faa9940e8ae5878babe326cc76f39ba930 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 14:06:59 +0900 Subject: [PATCH 43/70] Change second occurrence of debug.assert with early return for fallback safety --- osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index f88874f87213..a11f2f01cd71 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -159,7 +159,8 @@ public GlyphStore(string fontName, TextureStore textures, Func get public ITexturedCharacterGlyph? Get(string? fontName, char character) { // We only service one font. - Debug.Assert(fontName == this.fontName); + if (fontName != this.fontName) + return null; if (cache.TryGetValue(character, out var cached)) return cached; From 5970a68e2d12f4f8fc3a118cf68da7a69c1f1fcf Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 14:17:33 +0900 Subject: [PATCH 44/70] Use invalidation based logic for child anchor position updpates in `DrawableSlider` --- .../Objects/Drawables/DrawableSlider.cs | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 6cc8b8e935a1..4099d47d6188 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -11,6 +11,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; using osu.Game.Audio; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; @@ -36,8 +37,6 @@ public partial class DrawableSlider : DrawableOsuHitObject private ShakeContainer shakeContainer; - private Vector2? childAnchorPosition; - protected override IEnumerable DimmablePieces => new Drawable[] { HeadCircle, @@ -68,6 +67,8 @@ public partial class DrawableSlider : DrawableOsuHitObject private Container repeatContainer; private PausableSkinnableSound slidingSample; + private readonly LayoutValue drawSizeLayout; + public DrawableSlider() : this(null) { @@ -84,6 +85,7 @@ public DrawableSlider([CanBeNull] Slider s = null) AlwaysPresent = true, Alpha = 0 }; + AddLayout(drawSizeLayout = new LayoutValue(Invalidation.DrawSize | Invalidation.MiscGeometry)); } [BackgroundDependencyLoader] @@ -121,11 +123,7 @@ private void load() } }); - PositionBindable.BindValueChanged(_ => - { - Position = HitObject.StackedPosition; - childAnchorPosition = null; - }); + PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); ScaleBindable.BindValueChanged(scale => Ball.Scale = new Vector2(scale.NewValue)); @@ -258,17 +256,14 @@ protected override void Update() Size = SliderBody?.Size ?? Vector2.Zero; OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero; - if (DrawSize != Vector2.Zero) + if (!drawSizeLayout.IsValid) { Vector2 pos = Vector2.Divide(OriginPosition, DrawSize); + foreach (var obj in NestedHitObjects) + obj.RelativeAnchorPosition = pos; + Ball.RelativeAnchorPosition = pos; - if (pos != childAnchorPosition) - { - childAnchorPosition = pos; - foreach (var obj in NestedHitObjects) - obj.RelativeAnchorPosition = pos; - Ball.RelativeAnchorPosition = pos; - } + drawSizeLayout.Validate(); } } From 79ff767eba341cba9e4c81e3410eacf75eb4cd07 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 14:51:30 +0900 Subject: [PATCH 45/70] Remove unused using --- osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 8765d8485ac8..e70d35f74a8a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -11,7 +11,6 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; -using osu.Game.Overlays.Wiki; namespace osu.Game.Tests.Visual.Online { From 19d1fff5362b3652eb70c366f7ea361f89af4737 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 15:37:29 +0900 Subject: [PATCH 46/70] Use native query to avoid huge overheads when cleaning up realm files --- osu.Game/Database/RealmFileStore.cs | 9 +++------ osu.Game/Models/RealmFile.cs | 4 ++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index 1da64d5be8e6..f1ed3f4b63ff 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.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.Linq; using osu.Framework.Extensions; @@ -98,15 +99,11 @@ public void Cleanup() // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. realm.Write(r => { - // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707) - var files = r.All().ToList(); - - foreach (var file in files) + foreach (var file in r.All().Filter("Usages.@count = 0")) { totalFiles++; - if (file.BacklinksCount > 0) - continue; + Debug.Assert(file.BacklinksCount == 0); try { diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs index 2faa3f0ca6c8..4d1642fb5f4a 100644 --- a/osu.Game/Models/RealmFile.cs +++ b/osu.Game/Models/RealmFile.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Game.IO; using Realms; @@ -11,5 +12,8 @@ public class RealmFile : RealmObject, IFileInfo { [PrimaryKey] public string Hash { get; set; } = string.Empty; + + [Backlink(nameof(RealmNamedFileUsage.File))] + public IQueryable Usages { get; } = null!; } } From 1837b31f9b5e1d3a2b829fe97105a0abcc882e8b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 16:38:02 +0900 Subject: [PATCH 47/70] Remove usage of `HealthDisplay.BindValueChanged` Health updates very often when using HP drain. Let's avoid bindable overheads. --- osu.Game/Screens/Play/HUD/HealthDisplay.cs | 29 ++++++++--- osu.Game/Skinning/LegacyHealthDisplay.cs | 56 ++++++++++------------ 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 774703682670..a20121b20b0b 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -56,13 +56,6 @@ protected override void LoadComplete() // Don't bind directly so we can animate the startup procedure. health = HealthProcessor.Health.GetBoundCopy(); - health.BindValueChanged(h => - { - if (initialIncrease != null) - FinishInitialAnimation(h.OldValue); - - Current.Value = h.NewValue; - }); if (hudOverlay != null) showHealthBar.BindTo(hudOverlay.ShowHealthBar); @@ -76,6 +69,28 @@ protected override void LoadComplete() Current.Value = health.Value; } + protected override void Update() + { + base.Update(); + + // Health changes every frame in draining situations. + // Manually handle value changes to avoid bindable event flow overhead. + if (health.Value != Current.Value) + { + if (initialIncrease != null) + FinishInitialAnimation(Current.Value); + + Current.Value = health.Value; + + if (health.Value > Current.Value) + HealthIncreased(); + } + } + + protected virtual void HealthIncreased() + { + } + private void startInitialAnimation() { if (Current.Value >= health.Value) diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 845fc77394d1..00e19c4f768a 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -79,7 +79,13 @@ protected override void Update() marker.Position = fill.Position + new Vector2(fill.DrawWidth, isNewStyle ? fill.DrawHeight / 2 : 0); } - protected override void Flash() => marker.Flash(); + protected override void HealthIncreased() + { + marker.Bulge(); + base.HealthIncreased(); + } + + protected override void Flash() => marker.Flash(Current.Value >= epic_cutoff); private static Texture getTexture(ISkin skin, string name) => skin?.GetTexture($"scorebar-{name}"); @@ -113,19 +119,16 @@ public LegacyOldStyleMarker(ISkin skin) Origin = Anchor.Centre, }; - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); + base.Update(); - Current.BindValueChanged(hp => - { - if (hp.NewValue < 0.2f) - Main.Texture = superDangerTexture; - else if (hp.NewValue < epic_cutoff) - Main.Texture = dangerTexture; - else - Main.Texture = normalTexture; - }); + if (Current.Value < 0.2f) + Main.Texture = superDangerTexture; + else if (Current.Value < epic_cutoff) + Main.Texture = dangerTexture; + else + Main.Texture = normalTexture; } } @@ -226,37 +229,30 @@ private void load() public abstract Sprite CreateSprite(); - protected override void LoadComplete() + public override void Flash(bool isEpic) { - base.LoadComplete(); - - Current.BindValueChanged(val => - { - if (val.NewValue > val.OldValue) - bulgeMain(); - }); - } - - public override void Flash() - { - bulgeMain(); - - bool isEpic = Current.Value >= epic_cutoff; - + Bulge(); explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120); explode.FadeOutFromOne(120); } - private void bulgeMain() => + public override void Bulge() + { + base.Bulge(); Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); + } } public partial class LegacyHealthPiece : CompositeDrawable { public Bindable Current { get; } = new Bindable(); - public virtual void Flash() + public virtual void Bulge() + { + } + + public virtual void Flash(bool isEpic) { } } From d83b8dbdaf489320b1ef2632e7b9fba5a3d7c26a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 16:50:27 +0900 Subject: [PATCH 48/70] Refactor `ArgonHealthDisplay` to user interpolation and less bindable events --- .../Screens/Play/HUD/ArgonHealthDisplay.cs | 39 +++++++------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 8acc43c091da..f15789601ce1 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -158,7 +158,6 @@ protected override void LoadComplete() base.LoadComplete(); HealthProcessor.NewJudgement += onNewJudgement; - Current.BindValueChanged(onCurrentChanged, true); // we're about to set `RelativeSizeAxes` depending on the value of `UseRelativeSize`. // setting `RelativeSizeAxes` internally transforms absolute sizing to relative and back to keep the size the same, @@ -173,31 +172,6 @@ protected override void LoadComplete() private void onNewJudgement(JudgementResult result) => pendingMissAnimation |= !result.IsHit; - private void onCurrentChanged(ValueChangedEvent valueChangedEvent) - // schedule display updates one frame later to ensure we know the judgement result causing this change (if there is one). - => Scheduler.AddOnce(updateDisplay); - - private void updateDisplay() - { - double newHealth = Current.Value; - - if (newHealth >= GlowBarValue) - finishMissDisplay(); - - double time = newHealth > GlowBarValue ? 500 : 250; - - // TODO: this should probably use interpolation in update. - this.TransformTo(nameof(HealthBarValue), newHealth, time, Easing.OutQuint); - - if (pendingMissAnimation && newHealth < GlowBarValue) - triggerMissDisplay(); - - pendingMissAnimation = false; - - if (!displayingMiss) - this.TransformTo(nameof(GlowBarValue), newHealth, time, Easing.OutQuint); - } - protected override void Update() { base.Update(); @@ -210,6 +184,19 @@ protected override void Update() mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + + double newHealth = Current.Value; + + if (newHealth >= GlowBarValue) + finishMissDisplay(); + + if (pendingMissAnimation && newHealth < GlowBarValue) + triggerMissDisplay(); + pendingMissAnimation = false; + + HealthBarValue = Interpolation.DampContinuously(HealthBarValue, newHealth, 50, Time.Elapsed); + if (!displayingMiss) + GlowBarValue = Interpolation.DampContinuously(GlowBarValue, newHealth, 50, Time.Elapsed); } protected override void FinishInitialAnimation(double value) From f1b4c305b02df1d0495f30f2390bd168cdbf8249 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 17:09:19 +0900 Subject: [PATCH 49/70] Change skinnable health test scene to drain --- .../Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs index 15a7b48323bb..f215a5d52800 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -35,6 +35,13 @@ public void SetUpSteps() }); } + protected override void Update() + { + base.Update(); + + healthProcessor.Health.Value -= 0.0001f * Time.Elapsed; + } + [Test] public void TestHealthDisplayIncrementing() { From b3533d270c0c9ad70cb89919a3c55d3899be2fd3 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 17:20:49 +0900 Subject: [PATCH 50/70] Remove delegate overhead of `HealthBarValue`/`GlowBarValue` --- .../Screens/Play/HUD/ArgonHealthDisplay.cs | 44 +++++-------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index f15789601ce1..43de99b4b5a3 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -63,34 +63,8 @@ public partial class ArgonHealthDisplay : HealthDisplay, ISerialisableDrawable private double glowBarValue; - public double GlowBarValue - { - get => glowBarValue; - set - { - if (glowBarValue == value) - return; - - glowBarValue = value; - Scheduler.AddOnce(updatePathVertices); - } - } - private double healthBarValue; - public double HealthBarValue - { - get => healthBarValue; - set - { - if (healthBarValue == value) - return; - - healthBarValue = value; - Scheduler.AddOnce(updatePathVertices); - } - } - public const float MAIN_PATH_RADIUS = 10f; private const float curve_start_offset = 70; @@ -183,27 +157,29 @@ protected override void Update() } mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); - glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, glowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); double newHealth = Current.Value; - if (newHealth >= GlowBarValue) + if (newHealth >= glowBarValue) finishMissDisplay(); - if (pendingMissAnimation && newHealth < GlowBarValue) + if (pendingMissAnimation && newHealth < glowBarValue) triggerMissDisplay(); pendingMissAnimation = false; - HealthBarValue = Interpolation.DampContinuously(HealthBarValue, newHealth, 50, Time.Elapsed); + healthBarValue = Interpolation.DampContinuously(healthBarValue, newHealth, 50, Time.Elapsed); if (!displayingMiss) - GlowBarValue = Interpolation.DampContinuously(GlowBarValue, newHealth, 50, Time.Elapsed); + glowBarValue = Interpolation.DampContinuously(glowBarValue, newHealth, 50, Time.Elapsed); + + updatePathVertices(); } protected override void FinishInitialAnimation(double value) { base.FinishInitialAnimation(value); - this.TransformTo(nameof(HealthBarValue), value, 500, Easing.OutQuint); - this.TransformTo(nameof(GlowBarValue), value, 250, Easing.OutQuint); + this.TransformTo(nameof(healthBarValue), value, 500, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), value, 250, Easing.OutQuint); } protected override void Flash() @@ -232,7 +208,7 @@ private void triggerMissDisplay() this.Delay(500).Schedule(() => { - this.TransformTo(nameof(GlowBarValue), Current.Value, 300, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), Current.Value, 300, Easing.OutQuint); finishMissDisplay(); }, out resetMissBarDelegate); From 12a59eb34cb44322315ec3608de8c112b2b217ac Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 17:30:13 +0900 Subject: [PATCH 51/70] Remove vertex update overheads --- .../Screens/Play/HUD/ArgonHealthDisplay.cs | 50 ++++++++++--------- osu.Game/Screens/Play/HUD/HealthDisplay.cs | 9 ++-- osu.Game/Skinning/LegacyHealthDisplay.cs | 7 +-- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 43de99b4b5a3..e22f45b40d6d 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -58,8 +57,7 @@ public partial class ArgonHealthDisplay : HealthDisplay, ISerialisableDrawable private bool displayingMiss => resetMissBarDelegate != null; - private readonly List missBarVertices = new List(); - private readonly List healthBarVertices = new List(); + private readonly List vertices = new List(); private double glowBarValue; @@ -156,23 +154,27 @@ protected override void Update() drawSizeLayout.Validate(); } + healthBarValue = Interpolation.DampContinuously(healthBarValue, Current.Value, 50, Time.Elapsed); + if (!displayingMiss) + glowBarValue = Interpolation.DampContinuously(glowBarValue, Current.Value, 50, Time.Elapsed); + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, glowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); - double newHealth = Current.Value; + updatePathVertices(); + } - if (newHealth >= glowBarValue) + protected override void HealthChanged(bool increase) + { + if (Current.Value >= glowBarValue) finishMissDisplay(); - if (pendingMissAnimation && newHealth < glowBarValue) + if (pendingMissAnimation && Current.Value < glowBarValue) triggerMissDisplay(); - pendingMissAnimation = false; - healthBarValue = Interpolation.DampContinuously(healthBarValue, newHealth, 50, Time.Elapsed); - if (!displayingMiss) - glowBarValue = Interpolation.DampContinuously(glowBarValue, newHealth, 50, Time.Elapsed); + pendingMissAnimation = false; - updatePathVertices(); + base.HealthChanged(increase); } protected override void FinishInitialAnimation(double value) @@ -265,7 +267,6 @@ private void updatePath() if (DrawWidth - padding < rescale_cutoff) rescalePathProportionally(); - List vertices = new List(); barPath.GetPathToProgress(vertices, 0.0, 1.0); background.Vertices = vertices; @@ -295,20 +296,23 @@ void rescalePathProportionally() private void updatePathVertices() { - barPath.GetPathToProgress(healthBarVertices, 0.0, healthBarValue); - barPath.GetPathToProgress(missBarVertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + barPath.GetPathToProgress(vertices, 0.0, healthBarValue); + if (vertices.Count == 0) vertices.Add(Vector2.Zero); + Vector2 initialVertex = vertices[0]; + for (int i = 0; i < vertices.Count; i++) + vertices[i] -= initialVertex; - if (healthBarVertices.Count == 0) - healthBarVertices.Add(Vector2.Zero); - - if (missBarVertices.Count == 0) - missBarVertices.Add(Vector2.Zero); + mainBar.Vertices = vertices; + mainBar.Position = initialVertex; - glowBar.Vertices = missBarVertices.Select(v => v - missBarVertices[0]).ToList(); - glowBar.Position = missBarVertices[0]; + barPath.GetPathToProgress(vertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + if (vertices.Count == 0) vertices.Add(Vector2.Zero); + initialVertex = vertices[0]; + for (int i = 0; i < vertices.Count; i++) + vertices[i] -= initialVertex; - mainBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); - mainBar.Position = healthBarVertices[0]; + glowBar.Vertices = vertices; + glowBar.Position = initialVertex; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index a20121b20b0b..d5b238cd50f5 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; @@ -75,19 +76,17 @@ protected override void Update() // Health changes every frame in draining situations. // Manually handle value changes to avoid bindable event flow overhead. - if (health.Value != Current.Value) + if (!Precision.AlmostEquals(health.Value, Current.Value, 0.001f)) { if (initialIncrease != null) FinishInitialAnimation(Current.Value); + HealthChanged(Current.Value > health.Value); Current.Value = health.Value; - - if (health.Value > Current.Value) - HealthIncreased(); } } - protected virtual void HealthIncreased() + protected virtual void HealthChanged(bool increase) { } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 00e19c4f768a..9c06cbbfb5f5 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -79,10 +79,11 @@ protected override void Update() marker.Position = fill.Position + new Vector2(fill.DrawWidth, isNewStyle ? fill.DrawHeight / 2 : 0); } - protected override void HealthIncreased() + protected override void HealthChanged(bool increase) { - marker.Bulge(); - base.HealthIncreased(); + if (increase) + marker.Bulge(); + base.HealthChanged(increase); } protected override void Flash() => marker.Flash(Current.Value >= epic_cutoff); From b6505ba0634887d1d48db6aaa880a55622b3f715 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 18:16:53 +0900 Subject: [PATCH 52/70] Reduce colour tween overhead and mark other calls of concern --- osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index e22f45b40d6d..f1653c874228 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -188,15 +188,9 @@ protected override void Flash() { base.Flash(); - mainBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour.Opacity(0.8f)) - .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); - if (!displayingMiss) { - glowBar.TransformTo(nameof(BarPath.BarColour), Colour4.White, 30, Easing.OutQuint) - .Then() - .TransformTo(nameof(BarPath.BarColour), main_bar_colour, 1000, Easing.OutQuint); - + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.GlowColour), Colour4.White, 30, Easing.OutQuint) .Then() .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); @@ -214,9 +208,11 @@ private void triggerMissDisplay() finishMissDisplay(); }, out resetMissBarDelegate); + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.BarColour), new Colour4(255, 147, 147, 255), 100, Easing.OutQuint).Then() .TransformTo(nameof(BarPath.BarColour), new Colour4(255, 93, 93, 255), 800, Easing.OutQuint); + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255).Lighten(0.2f)) .TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255), 800, Easing.OutQuint); } @@ -228,6 +224,7 @@ private void finishMissDisplay() if (Current.Value > 0) { + // TODO: REMOVE THIS. It's recreating textures. glowBar.TransformTo(nameof(BarPath.BarColour), main_bar_colour, 300, Easing.In); glowBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.In); } From 80892f316754c4004112f9be1dbe4dc4a8723191 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 18:18:11 +0900 Subject: [PATCH 53/70] Fix misses not displaying properly --- osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index f1653c874228..78720ec08727 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -169,10 +169,11 @@ protected override void HealthChanged(bool increase) if (Current.Value >= glowBarValue) finishMissDisplay(); - if (pendingMissAnimation && Current.Value < glowBarValue) + if (pendingMissAnimation) + { triggerMissDisplay(); - - pendingMissAnimation = false; + pendingMissAnimation = false; + } base.HealthChanged(increase); } From 9c7e555237b29e241e22b0c40e14e94afdbec4bc Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 18:27:37 +0900 Subject: [PATCH 54/70] Fix initial animation not playing correctly --- osu.Game/Screens/Play/HUD/HealthDisplay.cs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index d5b238cd50f5..cd4d050b5249 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -36,6 +36,8 @@ public abstract partial class HealthDisplay : CompositeDrawable private BindableNumber health = null!; + protected bool InitialAnimationPlaying => initialIncrease != null; + private ScheduledDelegate? initialIncrease; /// @@ -64,25 +66,35 @@ protected override void LoadComplete() // this probably shouldn't be operating on `this.` showHealthBar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + initialHealthValue = health.Value; + if (PlayInitialIncreaseAnimation) startInitialAnimation(); else Current.Value = health.Value; } + private double lastValue; + private double initialHealthValue; + protected override void Update() { base.Update(); - // Health changes every frame in draining situations. - // Manually handle value changes to avoid bindable event flow overhead. - if (!Precision.AlmostEquals(health.Value, Current.Value, 0.001f)) + if (!InitialAnimationPlaying || health.Value != initialHealthValue) { + Current.Value = health.Value; + if (initialIncrease != null) FinishInitialAnimation(Current.Value); + } - HealthChanged(Current.Value > health.Value); - Current.Value = health.Value; + // Health changes every frame in draining situations. + // Manually handle value changes to avoid bindable event flow overhead. + if (!Precision.AlmostEquals(lastValue, Current.Value, 0.001f)) + { + HealthChanged(Current.Value > lastValue); + lastValue = Current.Value; } } From 6ac1c799bde83a8fb49ad44e37de703421a4097c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 18:34:20 +0900 Subject: [PATCH 55/70] Fix `SettingsToolboxGroup` allocating excessively due to missing cache validation --- osu.Game/Overlays/SettingsToolboxGroup.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index c0948c1eab0d..de13bd96d486 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -151,9 +151,12 @@ protected override void Update() base.Update(); if (!headerTextVisibilityCache.IsValid) + { // These toolbox grouped may be contracted to only show icons. // For now, let's hide the header to avoid text truncation weirdness in such cases. headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); + headerTextVisibilityCache.Validate(); + } } protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) From 66b3945cd644abd7704adde5284211768981a36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 9 Jan 2024 10:44:30 +0100 Subject: [PATCH 56/70] Move current screen check to better place --- osu.Game/Screens/Select/PlaySongSelect.cs | 3 --- osu.Game/Screens/Select/SongSelect.cs | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 3cf8de526778..4951504ff55d 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -92,9 +92,6 @@ protected override bool OnStart() { if (playerLoader != null) return false; - if (!this.IsCurrentScreen()) - return false; - modsAtGameplayStart = Mods.Value; // Ctrl+Enter should start map with autoplay enabled. diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 2d5c44e5a5a7..bf1724995adb 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -660,7 +660,8 @@ protected override void LogoArriving(OsuLogo logo, bool resuming) logo.Action = () => { - FinaliseSelection(); + if (this.IsCurrentScreen()) + FinaliseSelection(); return false; }; } From cac0b0de6dba025e99692d208cd56f545bb05660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 9 Jan 2024 11:38:01 +0100 Subject: [PATCH 57/70] Remove unused using directive --- osu.Game/Database/RealmFileStore.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index f1ed3f4b63ff..1bd22af4c74e 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -4,7 +4,6 @@ using System; using System.Diagnostics; using System.IO; -using System.Linq; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; From a8a70be04ab13f0ad4c565b86ee206c19ba01d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 9 Jan 2024 11:49:42 +0100 Subject: [PATCH 58/70] Reference property via `nameof` rather than hardcoding --- osu.Game/Database/RealmFileStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index 1bd22af4c74e..9683baec69d3 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -98,7 +98,7 @@ public void Cleanup() // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. realm.Write(r => { - foreach (var file in r.All().Filter("Usages.@count = 0")) + foreach (var file in r.All().Filter(@$"{nameof(RealmFile.Usages)}.@count = 0")) { totalFiles++; From 4110adc4c0a9fdc763ec73061169a5e4e8952a7c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 9 Jan 2024 20:16:27 +0900 Subject: [PATCH 59/70] Fix missing wireframe on argon combo counter --- .../Screens/Play/HUD/ArgonAccuracyCounter.cs | 1 + .../Screens/Play/HUD/ArgonComboCounter.cs | 24 +++++++++++++++++++ osu.Game/Screens/Play/HUD/ComboCounter.cs | 5 ---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index 521ad6342634..5284e3167a1c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -83,6 +83,7 @@ public ArgonAccuracyTextComponent() }, fractionPart = new ArgonCounterTextComponent(Anchor.TopLeft) { + RequiredDisplayDigits = { Value = 2 }, WireframeOpacity = { BindTarget = WireframeOpacity }, Scale = new Vector2(0.5f), }, diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index 5ea7fd0b8280..af884aa4419f 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -57,6 +57,30 @@ private void load(ScoreProcessor scoreProcessor) }); } + public override int DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + text.RequiredDisplayDigits.Value = getDigitsRequiredForDisplayCount(); + } + + private int getDigitsRequiredForDisplayCount() + { + int digitsRequired = 1; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + protected override LocalisableString FormatCount(int count) => $@"{count}x"; protected override IHasText CreateText() => text = new ArgonCounterTextComponent(Anchor.TopLeft, MatchesStrings.MatchScoreStatsCombo.ToUpper()) diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs index 17531281aad8..93802e11c212 100644 --- a/osu.Game/Screens/Play/HUD/ComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ComboCounter.cs @@ -11,11 +11,6 @@ public abstract partial class ComboCounter : RollingCounter, ISerialisableD { public bool UsesFixedAnchor { get; set; } - protected ComboCounter() - { - Current.Value = DisplayedCount = 0; - } - protected override double GetProportionalDuration(int currentValue, int newValue) { return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; From 77bf6e3244111b026f930a75fbb35dd497d9c921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 9 Jan 2024 13:59:27 +0100 Subject: [PATCH 60/70] Fix missing wireframe behind "x" sign on combo counter display --- osu.Game/Screens/Play/HUD/ArgonComboCounter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index af884aa4419f..1d6ca3c89320 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -74,7 +74,8 @@ private void updateWireframe() private int getDigitsRequiredForDisplayCount() { - int digitsRequired = 1; + // one for the single presumed starting digit, one for the "x" at the end. + int digitsRequired = 2; long c = DisplayedCount; while ((c /= 10) > 0) digitsRequired++; From 92ba77031403632ad03b86efcbd2754a47b6c646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 9 Jan 2024 14:00:58 +0100 Subject: [PATCH 61/70] Fix missing wireframe behind percent sign on accuracy counter --- osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index 5284e3167a1c..171aa3f44b8d 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -90,6 +90,7 @@ public ArgonAccuracyTextComponent() percentText = new ArgonCounterTextComponent(Anchor.TopLeft) { Text = @"%", + RequiredDisplayDigits = { Value = 1 }, WireframeOpacity = { BindTarget = WireframeOpacity } }, } From 91677158a058e03bae2bace8d236c1ef12a5e66e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 10 Jan 2024 22:33:00 +0900 Subject: [PATCH 62/70] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index b179b8b837db..56931bbcb4e6 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index 7e03ab50e286..c180baeab7aa 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -23,6 +23,6 @@ iossimulator-x64 - + From 49d13cda6b80fcd439a7d40723e2635c7792eee2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 10 Jan 2024 23:09:43 +0900 Subject: [PATCH 63/70] Fix failing test by setting health on source of truth --- .../Visual/Gameplay/TestSceneFailingLayer.cs | 18 ++++++++++-------- osu.Game/Screens/Play/HUD/FailingLayer.cs | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs index 235ada2d633a..684d263a586b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs @@ -22,6 +22,8 @@ public partial class TestSceneFailingLayer : OsuTestScene private readonly Bindable showHealth = new Bindable(); + private HealthProcessor healthProcessor; + [Resolved] private OsuConfigManager config { get; set; } @@ -29,7 +31,7 @@ private void create(HealthProcessor healthProcessor) { AddStep("create layer", () => { - Child = new HealthProcessorContainer(healthProcessor) + Child = new HealthProcessorContainer(this.healthProcessor = healthProcessor) { RelativeSizeAxes = Axes.Both, Child = layer = new FailingLayer() @@ -50,12 +52,12 @@ public void TestLayerFading() AddSliderStep("current health", 0.0, 1.0, 1.0, val => { if (layer != null) - layer.Current.Value = val; + healthProcessor.Health.Value = val; }); - AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddStep("set health to 0.10", () => healthProcessor.Health.Value = 0.1); AddUntilStep("layer fade is visible", () => layer.ChildrenOfType().First().Alpha > 0.1f); - AddStep("set health to 1", () => layer.Current.Value = 1f); + AddStep("set health to 1", () => healthProcessor.Health.Value = 1f); AddUntilStep("layer fade is invisible", () => !layer.ChildrenOfType().First().IsPresent); } @@ -65,7 +67,7 @@ public void TestLayerDisabledViaConfig() create(new DrainingHealthProcessor(0)); AddUntilStep("layer is visible", () => layer.IsPresent); AddStep("disable layer", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); - AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddStep("set health to 0.10", () => healthProcessor.Health.Value = 0.1); AddUntilStep("layer is not visible", () => !layer.IsPresent); } @@ -74,7 +76,7 @@ public void TestLayerVisibilityWithAccumulatingProcessor() { create(new AccumulatingHealthProcessor(1)); AddUntilStep("layer is not visible", () => !layer.IsPresent); - AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddStep("set health to 0.10", () => healthProcessor.Health.Value = 0.1); AddUntilStep("layer is not visible", () => !layer.IsPresent); } @@ -82,7 +84,7 @@ public void TestLayerVisibilityWithAccumulatingProcessor() public void TestLayerVisibilityWithDrainingProcessor() { create(new DrainingHealthProcessor(0)); - AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddStep("set health to 0.10", () => healthProcessor.Health.Value = 0.1); AddWaitStep("wait for potential fade", 10); AddAssert("layer is still visible", () => layer.IsPresent); } @@ -92,7 +94,7 @@ public void TestLayerVisibilityWithDifferentOptions() { create(new DrainingHealthProcessor(0)); - AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddStep("set health to 0.10", () => healthProcessor.Health.Value = 0.1); AddStep("don't show health", () => showHealth.Value = false); AddStep("disable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs index 3954e23cbe31..2bac7660b3e2 100644 --- a/osu.Game/Screens/Play/HUD/FailingLayer.cs +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -100,11 +100,11 @@ private void updateState() protected override void Update() { + base.Update(); + double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha); boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f); - - base.Update(); } } } From c2706ca91be7293a47142c0ae184774a17b263f6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 10 Jan 2024 23:48:16 +0900 Subject: [PATCH 64/70] Also show drain on argon health display test --- .../Visual/Gameplay/TestSceneArgonHealthDisplay.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs index 30fb4412f46f..06d199513c28 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs @@ -159,5 +159,11 @@ private void applyPerfectHit() Type = HitResult.Perfect }); } + + protected override void Update() + { + base.Update(); + healthProcessor.Health.Value -= 0.0001f * Time.Elapsed; + } } } From 5d6f767dbdc613ca54bbfdb98880c33517769b1c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 10 Jan 2024 23:52:39 +0900 Subject: [PATCH 65/70] Reduce excessive `Color4` allocations during path colour updates --- osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index afc64b0b8415..236bd3366d80 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -328,14 +328,17 @@ protected override void Dispose(bool isDisposing) private partial class BackgroundPath : SmoothPath { + private static readonly Color4 colour_white = Color4.White.Opacity(0.8f); + private static readonly Color4 colour_black = Color4.Black.Opacity(0.2f); + protected override Color4 ColourAt(float position) { if (position <= 0.16f) - return Color4.White.Opacity(0.8f); + return colour_white; return Interpolation.ValueAt(position, - Color4.White.Opacity(0.8f), - Color4.Black.Opacity(0.2f), + colour_white, + colour_black, -0.5f, 1f, Easing.OutQuint); } } @@ -374,12 +377,14 @@ public Colour4 GlowColour public float GlowPortion { get; init; } + private static readonly Colour4 transparent_black = Colour4.Black.Opacity(0.0f); + protected override Color4 ColourAt(float position) { if (position >= GlowPortion) return BarColour; - return Interpolation.ValueAt(position, Colour4.Black.Opacity(0.0f), GlowColour, 0.0, GlowPortion, Easing.InQuint); + return Interpolation.ValueAt(position, transparent_black, GlowColour, 0.0, GlowPortion, Easing.InQuint); } } } From 861080d3ae42de1b57e4471abc2f4f43a3026fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 11 Jan 2024 10:04:37 +0100 Subject: [PATCH 66/70] Move simulated drain to separate test case Having it on at all times was causing other tests to fail. --- .../Visual/Gameplay/TestSceneArgonHealthDisplay.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs index 06d199513c28..5d2921107e8a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; @@ -160,10 +161,14 @@ private void applyPerfectHit() }); } - protected override void Update() + [Test] + public void TestSimulateDrain() { - base.Update(); - healthProcessor.Health.Value -= 0.0001f * Time.Elapsed; + ScheduledDelegate del = null!; + + AddStep("simulate drain", () => del = Scheduler.AddDelayed(() => healthProcessor.Health.Value -= 0.00025f * Time.Elapsed, 0, true)); + AddUntilStep("wait until zero", () => healthProcessor.Health.Value == 0); + AddStep("cancel drain", () => del.Cancel()); } } } From 600e4b6ef3c930ad8dc82f513c870582747e5bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 11 Jan 2024 10:17:32 +0100 Subject: [PATCH 67/70] Adjust skinnable health display test scene for usability --- .../Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs index f215a5d52800..1849e8abd005 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -21,7 +21,7 @@ public partial class TestSceneSkinnableHealthDisplay : SkinnableHUDComponentTest [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); - protected override Drawable CreateArgonImplementation() => new ArgonHealthDisplay { Scale = new Vector2(0.6f), Width = 1f }; + protected override Drawable CreateArgonImplementation() => new ArgonHealthDisplay { Scale = new Vector2(0.6f), Width = 600, UseRelativeSize = { Value = false } }; protected override Drawable CreateDefaultImplementation() => new DefaultHealthDisplay { Scale = new Vector2(0.6f) }; protected override Drawable CreateLegacyImplementation() => new LegacyHealthDisplay { Scale = new Vector2(0.6f) }; From 16ec602240f32ccca08c239be4361ecb5a735b10 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Thu, 11 Jan 2024 23:01:05 +0000 Subject: [PATCH 68/70] add log1p, erf, erfinv and nelder mead simplex methods to remove mathnet dependency --- osu.Game/Utils/NelderMeadSimplex.cs | 308 ++++++++++ osu.Game/Utils/StatUtils.cs | 731 +++++++++++++++++++++++ osu.Game/Utils/ValueObjectiveFunction.cs | 33 + 3 files changed, 1072 insertions(+) create mode 100644 osu.Game/Utils/NelderMeadSimplex.cs create mode 100644 osu.Game/Utils/StatUtils.cs create mode 100644 osu.Game/Utils/ValueObjectiveFunction.cs diff --git a/osu.Game/Utils/NelderMeadSimplex.cs b/osu.Game/Utils/NelderMeadSimplex.cs new file mode 100644 index 000000000000..b14e239233ae --- /dev/null +++ b/osu.Game/Utils/NelderMeadSimplex.cs @@ -0,0 +1,308 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; + +// all code referenced from: +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs + +namespace osu.Game.Utils +{ + public static class NelderMeadSimplex + { + private const double jitter = 1e-10d; + + /// + /// Finds the minimum of the objective function without an initial perturbation, the default values used + /// by fminsearch() in Matlab are used instead + /// http://se.mathworks.com/help/matlab/math/optimizing-nonlinear-functions.html#bsgpq6p-11 + /// + /// The objective function, no gradient or hessian needed + /// The initial guess + /// The convergence tolerance + /// The maximum iterations + /// The minimum point + public static double Minimum(ValueObjectiveFunction objectiveFunction, double initialGuess, double convergenceTolerance = 1e-8, int maximumIterations = 1000) + { + double initialPerturbation = initialGuess == 0.0 ? 0.00025 : initialGuess * 0.05; + + return Minimum(objectiveFunction, initialGuess, initialPerturbation, convergenceTolerance, maximumIterations); + } + + public static double Minimum(ValueObjectiveFunction objectiveFunction, double initialGuess, double initialPerturbation, double convergenceTolerance = 1e-8, int maximumIterations = 1000) + { + // we only allow taking in a single guess, and `numVertices` is equal to `numDimensions + 1` which always calculates to 2 in this case. + const int num_vertices = 2; + + double[][] vertices = initializeVertices(initialGuess, initialPerturbation); + + int evaluationCount = 0; + ErrorProfile errorProfile; + + double[] errorValues = initializeErrorValues(vertices, objectiveFunction); + int numTimesHasConverged = 0; + + // iterate until we converge, or complete our permitted number of iterations + while (true) + { + errorProfile = evaluateSimplex(errorValues); + + if (hasConverged(convergenceTolerance, errorProfile, errorValues)) + { + numTimesHasConverged++; + } + else + { + numTimesHasConverged = 0; + } + + if (numTimesHasConverged == 2) + { + break; + } + + // attempt a reflection of the simplex + double reflectionPointValue = tryToScaleSimplex(-1.0, ref errorProfile, vertices, errorValues, objectiveFunction); + ++evaluationCount; + + if (reflectionPointValue <= errorValues[errorProfile.LowestIndex]) + { + // it's better than the be st point, so attempt an expansion of the simplex + tryToScaleSimplex(2.0, ref errorProfile, vertices, errorValues, objectiveFunction); + ++evaluationCount; + } + else if (reflectionPointValue >= errorValues[errorProfile.NextHighestIndex]) + { + // it would be worse than the second best point, so attempt a contraction to look + // for an intermediate point + double currentWorst = errorValues[errorProfile.HighestIndex]; + double contractionPointValue = tryToScaleSimplex(0.5, ref errorProfile, vertices, errorValues, objectiveFunction); + ++evaluationCount; + + if (contractionPointValue >= currentWorst) + { + // that would be even worse, so let's try to contract uniformly towards the low point; + // don't bother to update the error profile, we'll do it at the start of the + // next iteration + shrinkSimplex(errorProfile, vertices, errorValues, objectiveFunction); + evaluationCount += num_vertices; // that required one function evaluation for each vertex; keep track + } + } + + // check to see if we have exceeded our alloted number of evaluations + if (evaluationCount >= maximumIterations) + { + throw new InvalidOperationException($"Exceeded maximum ({maximumIterations}) simplex evaluations"); + } + } + + objectiveFunction.EvaluateAt(vertices[errorProfile.LowestIndex]); + return objectiveFunction.Point[0]; + } + + private static void shrinkSimplex(ErrorProfile errorProfile, double[][] vertices, double[] errorValues, ValueObjectiveFunction objectiveFunction) + { + double[] lowestVertex = vertices[errorProfile.LowestIndex]; + + for (int i = 0; i < vertices.Length; i++) + { + if (i != errorProfile.LowestIndex) + { + vertices[i] = (vertices[i].AddVector(lowestVertex)).Multiply(0.5); + objectiveFunction.EvaluateAt(vertices[i]); + errorValues[i] = objectiveFunction.Value; + } + } + } + + private static double tryToScaleSimplex(double scaleFactor, ref ErrorProfile errorProfile, double[][] vertices, double[] errorValues, ValueObjectiveFunction objectiveFunction) + { + // find the centroid through which we will reflect + double[] centroid = computeCentroid(vertices, errorProfile); + + // define the vector from the centroid to the high point + double[] centroidToHighPoint = vertices[errorProfile.HighestIndex].Subtract(centroid); + + // scale and position the vector to determine the new trial point + double[] newPoint = centroidToHighPoint.Multiply(scaleFactor).AddVector(centroid); + + // evaluate the new point + objectiveFunction.EvaluateAt(newPoint); + double newErrorValue = objectiveFunction.Value; + + // if it's better, replace the old high point + if (newErrorValue < errorValues[errorProfile.HighestIndex]) + { + vertices[errorProfile.HighestIndex] = newPoint; + errorValues[errorProfile.HighestIndex] = newErrorValue; + } + + return newErrorValue; + } + + private static double[] computeCentroid(double[][] vertices, ErrorProfile errorProfile) + { + int numVertices = vertices.Length; + + List centroid = new List(numVertices - 1); + + for (int i = 0; i < numVertices; i++) + { + if (i != errorProfile.HighestIndex) + { + centroid.Add(0); + centroid = centroid.AddVector(vertices[i]); + } + } + + return centroid.Multiply(1.0d / (numVertices - 1)); + } + + private static ErrorProfile evaluateSimplex(double[] errorValues) + { + ErrorProfile errorProfile = new ErrorProfile(); + + if (errorValues[0] > errorValues[1]) + { + errorProfile.HighestIndex = 0; + errorProfile.NextHighestIndex = 1; + } + else + { + errorProfile.HighestIndex = 1; + errorProfile.NextHighestIndex = 0; + } + + for (int index = 0; index < errorValues.Length; index++) + { + double errorValue = errorValues[index]; + + if (errorValue <= errorValues[errorProfile.LowestIndex]) + { + errorProfile.LowestIndex = index; + } + + if (errorValue > errorValues[errorProfile.HighestIndex]) + { + errorProfile.NextHighestIndex = errorProfile.HighestIndex; // downgrade the current highest to next highest + errorProfile.HighestIndex = index; + } + else if (errorValue > errorValues[errorProfile.NextHighestIndex] && index != errorProfile.HighestIndex) + { + errorProfile.NextHighestIndex = index; + } + } + + return errorProfile; + } + + private static bool hasConverged(double convergenceTolerance, ErrorProfile errorProfile, double[] errorValues) + { + double range = 2 * Math.Abs(errorValues[errorProfile.HighestIndex] - errorValues[errorProfile.LowestIndex]) / + (Math.Abs(errorValues[errorProfile.HighestIndex]) + Math.Abs(errorValues[errorProfile.LowestIndex]) + jitter); + + return range < convergenceTolerance; + } + + private static double[] initializeErrorValues(double[][] vertices, ValueObjectiveFunction valueObjectiveFunction) + { + double[] errorValues = new double[vertices.Length]; + + for (int i = 0; i < vertices.Length; i++) + { + valueObjectiveFunction.EvaluateAt(vertices[i]); + errorValues[i] = valueObjectiveFunction.Value; + } + + return errorValues; + } + + private static double[][] initializeVertices(double value, double initialPerturbation) + { + // we only allow taking in a single guess, and `numVertices` is equal to `numDimensions + 1` which always calculates to 2 in this case. + double[][] vertices = new[] + { + new[] { value }, + new[] { value + (1 * initialPerturbation) } + }; + + return vertices; + } + + private sealed class ErrorProfile + { + public int HighestIndex { get; set; } + public int NextHighestIndex { get; set; } + public int LowestIndex { get; set; } + } + } + + public static class Extensions + { + public static double[] Subtract(this double[] current, double[] other) + { + var result = new List(); + + for (int i = 0; i < current.Length; i++) + { + double negated = current[i] - other[i]; + result.Add(negated); + } + + return result.ToArray(); + } + + public static List AddVector(this List current, double[] other) + { + var result = new List(); + + for (int i = 0; i < current.Count; i++) + { + double sum = current[i] + other[i]; + result.Add(sum); + } + + return result; + } + + public static double[] AddVector(this double[] current, double[] other) + { + var result = new List(); + + for (int i = 0; i < current.Length; i++) + { + double sum = current[i] + other[i]; + result.Add(sum); + } + + return result.ToArray(); + } + + public static double[] Multiply(this List current, double other) + { + var result = new List(); + + for (int i = 0; i < current.Count; i++) + { + double sum = current[i] * other; + result.Add(sum); + } + + return result.ToArray(); + } + + public static double[] Multiply(this double[] current, double other) + { + var result = new List(); + + for (int i = 0; i < current.Length; i++) + { + double sum = current[i] * other; + result.Add(sum); + } + + return result.ToArray(); + } + } +} diff --git a/osu.Game/Utils/StatUtils.cs b/osu.Game/Utils/StatUtils.cs new file mode 100644 index 000000000000..6ecd3b74ace2 --- /dev/null +++ b/osu.Game/Utils/StatUtils.cs @@ -0,0 +1,731 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +// All code is referenced from the following: +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs + +using System; + +namespace osu.Game.Utils +{ + public static class StatUtils + { + /// + /// ************************************** + /// COEFFICIENTS FOR METHOD ErfImp * + /// ************************************** + /// + /// Polynomial coefficients for a numerator of ErfImp + /// calculation for Erf(x) in the interval [1e-10, 0.5]. + /// + private static readonly double[] erf_imp_an = { 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 }; + + /// Polynomial coefficients for a denominator of ErfImp + /// calculation for Erf(x) in the interval [1e-10, 0.5]. + /// + private static readonly double[] erf_imp_ad = { 1, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [0.5, 0.75]. + /// + private static readonly double[] erf_imp_bn = { -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [0.5, 0.75]. + /// + private static readonly double[] erf_imp_bd = { 1, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [0.75, 1.25]. + /// + private static readonly double[] erf_imp_cn = { -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [0.75, 1.25]. + /// + private static readonly double[] erf_imp_cd = { 1, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [1.25, 2.25]. + /// + private static readonly double[] erf_imp_dn = { -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [1.25, 2.25]. + /// + private static readonly double[] erf_imp_dd = { 1, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [2.25, 3.5]. + /// + private static readonly double[] erf_imp_en = { -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [2.25, 3.5]. + /// + private static readonly double[] erf_imp_ed = { 1, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [3.5, 5.25]. + /// + private static readonly double[] erf_imp_fn = { -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [3.5, 5.25]. + /// + private static readonly double[] erf_imp_fd = { 1, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [5.25, 8]. + /// + private static readonly double[] erf_imp_gn = { -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [5.25, 8]. + /// + private static readonly double[] erf_imp_gd = { 1, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [8, 11.5]. + /// + private static readonly double[] erf_imp_hn = { -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [8, 11.5]. + /// + private static readonly double[] erf_imp_hd = { 1, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [11.5, 17]. + /// + private static readonly double[] erf_imp_in = { -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [11.5, 17]. + /// + private static readonly double[] erf_imp_id = { 1, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [17, 24]. + /// + private static readonly double[] erf_imp_jn = { -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [17, 24]. + /// + private static readonly double[] erf_imp_jd = { 1, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [24, 38]. + /// + private static readonly double[] erf_imp_kn = { -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [24, 38]. + /// + private static readonly double[] erf_imp_kd = { 1, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [38, 60]. + /// + private static readonly double[] erf_imp_ln = { -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [38, 60]. + /// + private static readonly double[] erf_imp_ld = { 1, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [60, 85]. + /// + private static readonly double[] erf_imp_mn = { -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [60, 85]. + /// + private static readonly double[] erf_imp_md = { 1, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [85, 110]. + /// + private static readonly double[] erf_imp_nn = { -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [85, 110]. + /// + private static readonly double[] erf_imp_nd = { 1, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 }; + + /// + /// ************************************** + /// COEFFICIENTS FOR METHOD ErfInvImp * + /// ************************************** + /// + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0, 0.5]. + /// + private static readonly double[] erv_inv_imp_an = { -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0, 0.5]. + /// + private static readonly double[] erv_inv_imp_ad = { 1, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. + /// + private static readonly double[] erv_inv_imp_bn = { -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. + /// + private static readonly double[] erv_inv_imp_bd = { 1, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. + /// + private static readonly double[] erv_inv_imp_cn = { -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. + /// + private static readonly double[] erv_inv_imp_cd = { 1, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. + /// + private static readonly double[] erv_inv_imp_dn = { -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. + /// + private static readonly double[] erv_inv_imp_dd = { 1, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. + /// + private static readonly double[] erv_inv_imp_en = { -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. + /// + private static readonly double[] erv_inv_imp_ed = { 1, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. + /// + private static readonly double[] erv_inv_imp_fn = { -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. + /// + private static readonly double[] erv_inv_imp_fd = { 1, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. + /// + private static readonly double[] erv_inv_imp_gn = { -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. + /// + private static readonly double[] erv_inv_imp_gd = { 1, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 }; + + /// Calculates the error function. + /// The value to evaluate. + /// the error function evaluated at given value. + /// + /// + /// returns 1 if x == double.PositiveInfinity. + /// returns -1 if x == double.NegativeInfinity. + /// + /// + public static double Erf(double x) + { + if (x == 0) + { + return 0; + } + + if (double.IsPositiveInfinity(x)) + { + return 1; + } + + if (double.IsNegativeInfinity(x)) + { + return -1; + } + + if (double.IsNaN(x)) + { + return double.NaN; + } + + return erfImp(x, false); + } + + /// Calculates the complementary error function. + /// The value to evaluate. + /// the complementary error function evaluated at given value. + /// + /// + /// returns 0 if x == double.PositiveInfinity. + /// returns 2 if x == double.NegativeInfinity. + /// + /// + public static double Erfc(double x) + { + if (x == 0) + { + return 1; + } + + if (double.IsPositiveInfinity(x)) + { + return 0; + } + + if (double.IsNegativeInfinity(x)) + { + return 2; + } + + if (double.IsNaN(x)) + { + return double.NaN; + } + + return erfImp(x, true); + } + + /// Calculates the inverse error function evaluated at z. + /// The inverse error function evaluated at given value. + /// + /// + /// returns double.PositiveInfinity if z >= 1.0. + /// returns double.NegativeInfinity if z <= -1.0. + /// + /// + /// Calculates the inverse error function evaluated at z. + /// value to evaluate. + /// the inverse error function evaluated at Z. + public static double ErfInv(double z) + { + if (z == 0.0) + { + return 0.0; + } + + if (z >= 1.0) + { + return double.PositiveInfinity; + } + + if (z <= -1.0) + { + return double.NegativeInfinity; + } + + double p, q, s; + + if (z < 0) + { + p = -z; + q = 1 - p; + s = -1; + } + else + { + p = z; + q = 1 - z; + s = 1; + } + + return erfInvImpl(p, q, s); + } + + /// + /// Implementation of the error function. + /// + /// Where to evaluate the error function. + /// Whether to compute 1 - the error function. + /// the error function. + private static double erfImp(double z, bool invert) + { + if (z < 0) + { + if (!invert) + { + return -erfImp(-z, false); + } + + if (z < -0.5) + { + return 2 - erfImp(-z, true); + } + + return 1 + erfImp(-z, false); + } + + double result; + + // Big bunch of selection statements now to pick which + // implementation to use, try to put most likely options + // first: + if (z < 0.5) + { + // We're going to calculate erf: + if (z < 1e-10) + { + result = (z * 1.125) + (z * 0.003379167095512573896158903121545171688); + } + else + { + // Worst case absolute error found: 6.688618532e-21 + result = (z * 1.125) + (z * EvaluatePolynomial(z, erf_imp_an) / EvaluatePolynomial(z, erf_imp_ad)); + } + } + else if (z < 110) + { + // We'll be calculating erfc: + invert = !invert; + double r, b; + + if (z < 0.75) + { + // Worst case absolute error found: 5.582813374e-21 + r = EvaluatePolynomial(z - 0.5, erf_imp_bn) / EvaluatePolynomial(z - 0.5, erf_imp_bd); + b = 0.3440242112F; + } + else if (z < 1.25) + { + // Worst case absolute error found: 4.01854729e-21 + r = EvaluatePolynomial(z - 0.75, erf_imp_cn) / EvaluatePolynomial(z - 0.75, erf_imp_cd); + b = 0.419990927F; + } + else if (z < 2.25) + { + // Worst case absolute error found: 2.866005373e-21 + r = EvaluatePolynomial(z - 1.25, erf_imp_dn) / EvaluatePolynomial(z - 1.25, erf_imp_dd); + b = 0.4898625016F; + } + else if (z < 3.5) + { + // Worst case absolute error found: 1.045355789e-21 + r = EvaluatePolynomial(z - 2.25, erf_imp_en) / EvaluatePolynomial(z - 2.25, erf_imp_ed); + b = 0.5317370892F; + } + else if (z < 5.25) + { + // Worst case absolute error found: 8.300028706e-22 + r = EvaluatePolynomial(z - 3.5, erf_imp_fn) / EvaluatePolynomial(z - 3.5, erf_imp_fd); + b = 0.5489973426F; + } + else if (z < 8) + { + // Worst case absolute error found: 1.700157534e-21 + r = EvaluatePolynomial(z - 5.25, erf_imp_gn) / EvaluatePolynomial(z - 5.25, erf_imp_gd); + b = 0.5571740866F; + } + else if (z < 11.5) + { + // Worst case absolute error found: 3.002278011e-22 + r = EvaluatePolynomial(z - 8, erf_imp_hn) / EvaluatePolynomial(z - 8, erf_imp_hd); + b = 0.5609807968F; + } + else if (z < 17) + { + // Worst case absolute error found: 6.741114695e-21 + r = EvaluatePolynomial(z - 11.5, erf_imp_in) / EvaluatePolynomial(z - 11.5, erf_imp_id); + b = 0.5626493692F; + } + else if (z < 24) + { + // Worst case absolute error found: 7.802346984e-22 + r = EvaluatePolynomial(z - 17, erf_imp_jn) / EvaluatePolynomial(z - 17, erf_imp_jd); + b = 0.5634598136F; + } + else if (z < 38) + { + // Worst case absolute error found: 2.414228989e-22 + r = EvaluatePolynomial(z - 24, erf_imp_kn) / EvaluatePolynomial(z - 24, erf_imp_kd); + b = 0.5638477802F; + } + else if (z < 60) + { + // Worst case absolute error found: 5.896543869e-24 + r = EvaluatePolynomial(z - 38, erf_imp_ln) / EvaluatePolynomial(z - 38, erf_imp_ld); + b = 0.5640528202F; + } + else if (z < 85) + { + // Worst case absolute error found: 3.080612264e-21 + r = EvaluatePolynomial(z - 60, erf_imp_mn) / EvaluatePolynomial(z - 60, erf_imp_md); + b = 0.5641309023F; + } + else + { + // Worst case absolute error found: 8.094633491e-22 + r = EvaluatePolynomial(z - 85, erf_imp_nn) / EvaluatePolynomial(z - 85, erf_imp_nd); + b = 0.5641584396F; + } + + double g = Math.Exp(-z * z) / z; + result = (g * b) + (g * r); + } + else + { + // Any value of z larger than 28 will underflow to zero: + result = 0; + invert = !invert; + } + + if (invert) + { + result = 1 - result; + } + + return result; + } + + /// Calculates the complementary inverse error function evaluated at z. + /// The complementary inverse error function evaluated at given value. + /// We have tested this implementation against the arbitrary precision mpmath library + /// and found cases where we can only guarantee 9 significant figures correct. + /// + /// returns double.PositiveInfinity if z <= 0.0. + /// returns double.NegativeInfinity if z >= 2.0. + /// + /// + /// calculates the complementary inverse error function evaluated at z. + /// value to evaluate. + /// the complementary inverse error function evaluated at Z. + public static double ErfcInv(double z) + { + if (z <= 0.0) + { + return double.PositiveInfinity; + } + + if (z >= 2.0) + { + return double.NegativeInfinity; + } + + double p, q, s; + + if (z > 1) + { + q = 2 - z; + p = 1 - q; + s = -1; + } + else + { + p = 1 - z; + q = z; + s = 1; + } + + return erfInvImpl(p, q, s); + } + + /// + /// The implementation of the inverse error function. + /// + /// First intermediate parameter. + /// Second intermediate parameter. + /// Third intermediate parameter. + /// the inverse error function. + private static double erfInvImpl(double p, double q, double s) + { + double result; + + if (p <= 0.5) + { + // Evaluate inverse erf using the rational approximation: + // + // x = p(p+10)(Y+R(p)) + // + // Where Y is a constant, and R(p) is optimized for a low + // absolute error compared to |Y|. + // + // double: Max error found: 2.001849e-18 + // long double: Max error found: 1.017064e-20 + // Maximum Deviation Found (actual error term at infinite precision) 8.030e-21 + const float y = 0.0891314744949340820313f; + double g = p * (p + 10); + double r = EvaluatePolynomial(p, erv_inv_imp_an) / EvaluatePolynomial(p, erv_inv_imp_ad); + result = (g * y) + (g * r); + } + else if (q >= 0.25) + { + // Rational approximation for 0.5 > q >= 0.25 + // + // x = sqrt(-2*log(q)) / (Y + R(q)) + // + // Where Y is a constant, and R(q) is optimized for a low + // absolute error compared to Y. + // + // double : Max error found: 7.403372e-17 + // long double : Max error found: 6.084616e-20 + // Maximum Deviation Found (error term) 4.811e-20 + const float y = 2.249481201171875f; + double g = Math.Sqrt(-2 * Math.Log(q)); + double xs = q - 0.25; + double r = EvaluatePolynomial(xs, erv_inv_imp_bn) / EvaluatePolynomial(xs, erv_inv_imp_bd); + result = g / (y + r); + } + else + { + // For q < 0.25 we have a series of rational approximations all + // of the general form: + // + // let: x = sqrt(-log(q)) + // + // Then the result is given by: + // + // x(Y+R(x-B)) + // + // where Y is a constant, B is the lowest value of x for which + // the approximation is valid, and R(x-B) is optimized for a low + // absolute error compared to Y. + // + // Note that almost all code will really go through the first + // or maybe second approximation. After than we're dealing with very + // small input values indeed: 80 and 128 bit long double's go all the + // way down to ~ 1e-5000 so the "tail" is rather long... + double x = Math.Sqrt(-Math.Log(q)); + + if (x < 3) + { + // Max error found: 1.089051e-20 + const float y = 0.807220458984375f; + double xs = x - 1.125; + double r = EvaluatePolynomial(xs, erv_inv_imp_cn) / EvaluatePolynomial(xs, erv_inv_imp_cd); + result = (y * x) + (r * x); + } + else if (x < 6) + { + // Max error found: 8.389174e-21 + const float y = 0.93995571136474609375f; + double xs = x - 3; + double r = EvaluatePolynomial(xs, erv_inv_imp_dn) / EvaluatePolynomial(xs, erv_inv_imp_dd); + result = (y * x) + (r * x); + } + else if (x < 18) + { + // Max error found: 1.481312e-19 + const float y = 0.98362827301025390625f; + double xs = x - 6; + double r = EvaluatePolynomial(xs, erv_inv_imp_en) / EvaluatePolynomial(xs, erv_inv_imp_ed); + result = (y * x) + (r * x); + } + else if (x < 44) + { + // Max error found: 5.697761e-20 + const float y = 0.99714565277099609375f; + double xs = x - 18; + double r = EvaluatePolynomial(xs, erv_inv_imp_fn) / EvaluatePolynomial(xs, erv_inv_imp_fd); + result = (y * x) + (r * x); + } + else + { + // Max error found: 1.279746e-20 + const float y = 0.99941349029541015625f; + double xs = x - 44; + double r = EvaluatePolynomial(xs, erv_inv_imp_gn) / EvaluatePolynomial(xs, erv_inv_imp_gd); + result = (y * x) + (r * x); + } + } + + return s * result; + } + + /// + /// Evaluate a polynomial at point x. + /// Coefficients are ordered ascending by power with power k at index k. + /// Example: coefficients [3,-1,2] represent y=2x^2-x+3. + /// + /// The location where to evaluate the polynomial at. + /// The coefficients of the polynomial, coefficient for power k at index k. + /// + /// is a null reference. + /// + public static double EvaluatePolynomial(double z, params double[] coefficients) + { + // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably + // handle null arguments? It doesn't seem to have been done consistently in this class though. + if (coefficients == null) + { + throw new ArgumentNullException(nameof(coefficients)); + } + + // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. + // Without this check, we attempted to peek coefficients at negative indices! + int n = coefficients.Length; + + if (n == 0) + { + return 0; + } + + double sum = coefficients[n - 1]; + + for (int i = n - 2; i >= 0; --i) + { + sum *= z; + sum += coefficients[i]; + } + + return sum; + } + + /// + /// Computes ln(1+x) with good relative precision when |x| is small + /// + /// The parameter for which to compute the log1p function. Range: x > 0. + // ReSharper disable once InconsistentNaming + public static double Log1p(double x) + { + double y0 = Math.Log(1.0 + x); + + if ((-0.2928 < x) && (x < 0.4142)) + { + double y = y0; + + if (y == 0.0) + { + y = 1.0; + } + else if ((y < -0.69) || (y > 0.4)) + { + y = (Math.Exp(y) - 1.0) / y; + } + else + { + double t = y / 2.0; + y = Math.Exp(t) * Math.Sinh(t) / t; + } + + double s = y0 * y; + double r = (s - x) / (s + 1.0); + y0 = y0 - r * (6 - r) / (6 - 4 * r); + } + + return y0; + } + + /// + /// Find vector x that minimizes the function f(x) using the Nelder-Mead Simplex algorithm. + /// For more options and diagnostics consider to use directly. + /// + public static double FindMinimumOfScalarFunction(Func function, double initialGuess, double tolerance = 1e-8, int maxIterations = 1000) + { + var objective = new ValueObjectiveFunction(v => function(v[0])); + double minimum = NelderMeadSimplex.Minimum(objective, initialGuess, tolerance, maxIterations); + return minimum; + } + } +} diff --git a/osu.Game/Utils/ValueObjectiveFunction.cs b/osu.Game/Utils/ValueObjectiveFunction.cs new file mode 100644 index 000000000000..556484bedcd0 --- /dev/null +++ b/osu.Game/Utils/ValueObjectiveFunction.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +// all code referenced from: +// https://github.com/mathnet/mathnet-numerics/blob/f19641843048df073b80f6ecfcbb229d3258049b/src/Numerics/Optimization/ObjectiveFunctions/ValueObjectiveFunction.cs + +using System; + +namespace osu.Game.Utils +{ + public class ValueObjectiveFunction + { + private readonly Func function; + + public ValueObjectiveFunction(Func function) + { + this.function = function; + } + + public bool IsGradientSupported => false; + + public bool IsHessianSupported => false; + + public void EvaluateAt(double[] point) + { + Point = point; + Value = function(point); + } + + public double[] Point { get; private set; } = new double[] { }; + public double Value { get; private set; } + } +} From 9353f0d6bcfee84c4197d192235404b92c98e7dc Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Thu, 11 Jan 2024 23:05:44 +0000 Subject: [PATCH 69/70] add `NormalPdf` to `StatUtils` --- osu.Game/Utils/StatUtils.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/osu.Game/Utils/StatUtils.cs b/osu.Game/Utils/StatUtils.cs index 6ecd3b74ace2..0e0acf20b0b9 100644 --- a/osu.Game/Utils/StatUtils.cs +++ b/osu.Game/Utils/StatUtils.cs @@ -11,6 +11,8 @@ namespace osu.Game.Utils { public static class StatUtils { + private const double sqrt2_pi = 2.5066282746310005024157652848110452530069867406099d; + /// /// ************************************** /// COEFFICIENTS FOR METHOD ErfImp * @@ -727,5 +729,16 @@ public static double FindMinimumOfScalarFunction(Func function, double minimum = NelderMeadSimplex.Minimum(objective, initialGuess, tolerance, maxIterations); return minimum; } + + public static double NormalPdf(double mean, double stddev, double x) + { + if (stddev < 0.0) + { + throw new ArgumentException("Invalid parametrization for the distribution."); + } + + double d = (x - mean) / stddev; + return Math.Exp(-0.5 * d * d) / (sqrt2_pi * stddev); + } } } From dbb15122ab3c87a10aafb139c1d95de476dfcd51 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Thu, 11 Jan 2024 23:17:14 +0000 Subject: [PATCH 70/70] add MathNet license where necessary --- osu.Game/Utils/NelderMeadSimplex.cs | 10 ++++++++++ osu.Game/Utils/StatUtils.cs | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/osu.Game/Utils/NelderMeadSimplex.cs b/osu.Game/Utils/NelderMeadSimplex.cs index b14e239233ae..063fb57d6b01 100644 --- a/osu.Game/Utils/NelderMeadSimplex.cs +++ b/osu.Game/Utils/NelderMeadSimplex.cs @@ -7,6 +7,16 @@ // all code referenced from: // https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs +/* + Copyright (c) 2002-2022 Math.NET + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + namespace osu.Game.Utils { public static class NelderMeadSimplex diff --git a/osu.Game/Utils/StatUtils.cs b/osu.Game/Utils/StatUtils.cs index 0e0acf20b0b9..63b4292266a8 100644 --- a/osu.Game/Utils/StatUtils.cs +++ b/osu.Game/Utils/StatUtils.cs @@ -5,6 +5,16 @@ // https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs // https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs +/* + Copyright (c) 2002-2022 Math.NET + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + using System; namespace osu.Game.Utils