From 334a54e6f72edcea66f45e2ee8993d85a19eebba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 22 Dec 2025 13:21:59 +0100 Subject: [PATCH 1/3] Add way to add/remove custom beatmap samples to setup screen Among others, this features a scary-looking change wherein `WorkingBeatmap` now exposes a `RealmAccess` via `IStorageResourceProvider`. This is necessary to make `RealmBackedResourceStore` actually start firing callbacks when the edited beatmap's skin is modified by adding new samples to it. --- osu.Game/Beatmaps/BeatmapManager.cs | 2 +- osu.Game/Beatmaps/WorkingBeatmapCache.cs | 6 +- .../Screens/Edit/Components/FormSampleSet.cs | 19 ++- osu.Game/Screens/Edit/EditorBeatmap.cs | 10 +- osu.Game/Screens/Edit/EditorBeatmapSkin.cs | 51 +++++-- .../Edit/Setup/FormSampleSetChooser.cs | 144 ++++++++++++++++++ .../Screens/Edit/Setup/ResourcesSection.cs | 24 +++ osu.Game/Skinning/LegacyBeatmapSkin.cs | 2 + osu.Game/Skinning/RealmBackedResourceStore.cs | 8 +- osu.Game/Skinning/Skin.cs | 3 + 10 files changed, 248 insertions(+), 21 deletions(-) create mode 100644 osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index d50862a369..c67c5521d3 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -95,7 +95,7 @@ namespace osu.Game.Beatmaps protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, GameHost? host) { - return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host); + return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host, Realm); } protected virtual BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm) => new BeatmapImporter(storage, realm); diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 9957935977..0bb61798dc 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -47,12 +47,13 @@ namespace osu.Game.Beatmaps private readonly LargeTextureStore beatmapPanelTextureStore; private readonly ITrackStore trackStore; private readonly IResourceStore files; + private readonly RealmAccess realm; [CanBeNull] private readonly GameHost host; public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, - GameHost host = null) + GameHost host = null, RealmAccess realm = null) { DefaultBeatmap = defaultBeatmap; @@ -63,6 +64,7 @@ namespace osu.Game.Beatmaps largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files)); beatmapPanelTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), new BeatmapPanelBackgroundTextureLoaderStore(host?.CreateTextureLoaderStore(files))); this.trackStore = trackStore; + this.realm = realm; } public void Invalidate(BeatmapSetInfo info) @@ -118,7 +120,7 @@ namespace osu.Game.Beatmaps ITrackStore IBeatmapResourceProvider.Tracks => trackStore; IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer(); AudioManager IStorageResourceProvider.AudioManager => audioManager; - RealmAccess IStorageResourceProvider.RealmAccess => null!; + RealmAccess IStorageResourceProvider.RealmAccess => realm; IResourceStore IStorageResourceProvider.Files => files; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); diff --git a/osu.Game/Screens/Edit/Components/FormSampleSet.cs b/osu.Game/Screens/Edit/Components/FormSampleSet.cs index 50c0532c02..f2d31dea06 100644 --- a/osu.Game/Screens/Edit/Components/FormSampleSet.cs +++ b/osu.Game/Screens/Edit/Components/FormSampleSet.cs @@ -42,7 +42,7 @@ namespace osu.Game.Screens.Edit.Components set => current.Current = value; } - public Func? SampleAddRequested { get; init; } + public Func? SampleAddRequested { get; init; } public Action? SampleRemoveRequested { get; init; } private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -194,7 +194,7 @@ namespace osu.Game.Screens.Edit.Components /// /// Invoked when a new sample is selected via this button. /// - public Func? SampleAddRequested { get; init; } + public Func? SampleAddRequested { get; init; } /// /// Invoked when a sample removal is selected via this button. @@ -294,7 +294,18 @@ namespace osu.Game.Screens.Edit.Components AddInternal(hoverSounds = (ActualFilename.Value == null ? new HoverClickSounds(HoverSampleSet.Button) : new HoverSounds(HoverSampleSet.Button))); - sample = ActualFilename.Value == null ? null : editorBeatmap?.BeatmapSkin?.Skin.Samples?.Get(ActualFilename.Value); + if (ActualFilename.Value != null) + { + // to cover all bases, invalidate the extensionless filename (which gameplay is most likely to use) + // as well as the filename with extension (which we are using here). + editorBeatmap?.BeatmapSkin?.Skin.Samples?.Invalidate(ExpectedFilename.Value); + editorBeatmap?.BeatmapSkin?.Skin.Samples?.Invalidate(ActualFilename.Value); + sample = editorBeatmap?.BeatmapSkin?.Skin.Samples?.Get(ActualFilename.Value); + } + else + { + sample = null; + } } protected override bool OnHover(HoverEvent e) @@ -317,7 +328,7 @@ namespace osu.Game.Screens.Edit.Components return; this.HidePopover(); - ActualFilename.Value = SampleAddRequested?.Invoke(selectedFile.Value) ?? selectedFile.Value.ToString(); + ActualFilename.Value = SampleAddRequested?.Invoke(selectedFile.Value, ExpectedFilename.Value) ?? selectedFile.Value.ToString(); } private void deleteSample() diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 91ae4593dd..248779a9f8 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -101,9 +101,9 @@ namespace osu.Game.Screens.Edit this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; - if (beatmapSkin is Skin skin) + if (beatmapSkin is LegacyBeatmapSkin skin) { - BeatmapSkin = new EditorBeatmapSkin(skin); + BeatmapSkin = new EditorBeatmapSkin(playableBeatmap.BeatmapInfo!.BeatmapSet!, skin); BeatmapSkin.BeatmapSkinChanged += SaveState; } @@ -532,5 +532,11 @@ namespace osu.Game.Screens.Edit public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; public int BeatDivisor => beatDivisor?.Value ?? 1; + + protected override void Dispose(bool isDisposing) + { + BeatmapSkin?.Dispose(); + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index 531f77d238..0fbfb7b2d0 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.Beatmaps; using osu.Game.Skinning; using osuTK.Graphics; @@ -18,14 +19,14 @@ namespace osu.Game.Screens.Edit /// /// A beatmap skin which is being edited. /// - public class EditorBeatmapSkin : ISkin + public class EditorBeatmapSkin : ISkin, IDisposable { public event Action? BeatmapSkinChanged; /// /// The underlying beatmap skin. /// - protected internal readonly Skin Skin; + protected internal readonly LegacyBeatmapSkin Skin; /// /// The combo colours of this skin. @@ -33,7 +34,7 @@ namespace osu.Game.Screens.Edit /// public BindableList ComboColours { get; } - public EditorBeatmapSkin(Skin skin) + public EditorBeatmapSkin(BeatmapSetInfo beatmapSet, LegacyBeatmapSkin skin) { Skin = skin; @@ -50,9 +51,14 @@ namespace osu.Game.Screens.Edit } ComboColours.BindCollectionChanged((_, _) => updateColours()); + + if (skin.BeatmapSetResources != null) + skin.BeatmapSetResources.CacheInvalidated += InvokeSkinChanged; } - private void invokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + public void InvokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + + #region Combo colours private void updateColours() { @@ -60,9 +66,13 @@ namespace osu.Game.Screens.Edit Skin.Configuration.CustomComboColours.Clear(); for (int i = 0; i < ComboColours.Count; ++i) Skin.Configuration.CustomComboColours.Add(ComboColours[(ComboColours.Count + i - 1) % ComboColours.Count]); - invokeSkinChanged(); + InvokeSkinChanged(); } + #endregion + + #region Sample sets + public record SampleSet(int SampleSetIndex, string Name) { public SampleSet(int sampleSetIndex) @@ -88,7 +98,7 @@ namespace osu.Game.Screens.Edit string[] possiblePrefixes = possibleSounds.SelectMany(sound => possibleBanks.Select(bank => $@"{bank}-{sound}")).ToArray(); - HashSet indices = new HashSet(); + Dictionary sampleSets = new Dictionary(); if (Skin.Samples != null) { @@ -96,19 +106,38 @@ namespace osu.Game.Screens.Edit { foreach (string possiblePrefix in possiblePrefixes) { - if (!sample.StartsWith(possiblePrefix, StringComparison.InvariantCultureIgnoreCase)) + if (!sample.StartsWith(possiblePrefix, StringComparison.Ordinal)) continue; string indexString = Path.GetFileNameWithoutExtension(sample)[possiblePrefix.Length..]; + int? index = null; + if (string.IsNullOrEmpty(indexString)) - indices.Add(1); - if (int.TryParse(indexString, out int index)) - indices.Add(index); + index = 1; + if (int.TryParse(indexString, out int parsed)) + index = parsed; + + if (!index.HasValue) + continue; + + SampleSet? sampleSet; + if (!sampleSets.TryGetValue(index.Value, out sampleSet)) + sampleSet = sampleSets[index.Value] = new SampleSet(index.Value); + + sampleSet.Filenames.Add(sample); } } } - return indices.OrderBy(i => i).Select(i => new SampleSet(i)); + return sampleSets.OrderBy(i => i.Key).Select(i => i.Value); + } + + #endregion + + public void Dispose() + { + if (Skin.BeatmapSetResources != null) + Skin.BeatmapSetResources.CacheInvalidated -= InvokeSkinChanged; } #region Delegated ISkin implementation diff --git a/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs b/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs new file mode 100644 index 0000000000..750586dfe3 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/FormSampleSetChooser.cs @@ -0,0 +1,144 @@ +// 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.Globalization; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + public partial class FormSampleSetChooser : FormDropdown, IHasPopover + { + private EditorBeatmapSkin? beatmapSkin; + + public FormSampleSetChooser() + { + Caption = "Custom sample sets"; + } + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap) + { + beatmapSkin = editorBeatmap.BeatmapSkin; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + populateItems(); + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged += populateItems; + + Current.Value = Items.FirstOrDefault(i => i?.SampleSetIndex > 0); + Current.BindValueChanged(val => + { + if (val.NewValue?.SampleSetIndex == -1) + this.ShowPopover(); + }); + } + + private void populateItems() + { + var items = beatmapSkin?.GetAvailableSampleSets().ToList() ?? []; + items.Add(new EditorBeatmapSkin.SampleSet(-1, "Add new...")); + Items = items; + } + + protected override LocalisableString GenerateItemText(EditorBeatmapSkin.SampleSet? item) + { + if (item == null) + return string.Empty; + + return base.GenerateItemText(item); + } + + public Popover GetPopover() => new NewSampleSetPopover( + Items.Any(i => i?.SampleSetIndex > 0) ? Items.Max(i => i!.SampleSetIndex) : 0, + idx => + { + if (idx == null) + { + Current.Value = Items.FirstOrDefault(i => i?.SampleSetIndex > 0); + return; + } + + if (Items.SingleOrDefault(i => i?.SampleSetIndex == idx) is EditorBeatmapSkin.SampleSet existing) + { + Current.Value = existing; + return; + } + + var sampleSet = new EditorBeatmapSkin.SampleSet(idx.Value, $@"Custom #{idx}"); + var newItems = Items.ToList(); + newItems.Insert(newItems.Count - 1, sampleSet); + Items = newItems; + Current.Value = sampleSet; + }); + + protected override void Dispose(bool isDisposing) + { + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged -= populateItems; + + base.Dispose(isDisposing); + } + + private partial class NewSampleSetPopover : OsuPopover + { + private readonly int currentLargestIndex; + private readonly Action onCommit; + + private int? committedIndex; + + private LabelledNumberBox numberBox = null!; + + public NewSampleSetPopover(int currentLargestIndex, Action onCommit) + { + this.currentLargestIndex = currentLargestIndex; + this.onCommit = onCommit; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = numberBox = new LabelledNumberBox + { + RelativeSizeAxes = Axes.None, + Width = 250, + Label = "Sample set index", + Current = { Value = (currentLargestIndex + 1).ToString(CultureInfo.InvariantCulture) } + }; + numberBox.OnCommit += (_, _) => + { + committedIndex = int.Parse(numberBox.Current.Value); + Hide(); + }; + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + // avoids infinite refocus loop + if (committedIndex == null) + GetContainingFocusManager()?.ChangeFocus(numberBox); + } + + public override void Hide() + { + if (State.Value == Visibility.Visible) + onCommit.Invoke(committedIndex > 0 ? committedIndex : null); + base.Hide(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index f52d865d5f..8a55db8420 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -14,6 +14,7 @@ using osu.Game.Localisation; using osu.Game.Models; using osu.Game.Overlays; using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Edit.Components; using osu.Game.Utils; namespace osu.Game.Screens.Edit.Setup @@ -23,6 +24,8 @@ namespace osu.Game.Screens.Edit.Setup private FormBeatmapFileSelector audioTrackChooser = null!; private FormBeatmapFileSelector backgroundChooser = null!; + private readonly Bindable currentSampleSet = new Bindable(); + public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; [Resolved] @@ -65,6 +68,27 @@ namespace osu.Game.Screens.Edit.Setup Caption = EditorSetupStrings.AudioTrack, PlaceholderText = EditorSetupStrings.ClickToSelectTrack, }, + new FormSampleSetChooser + { + Current = { BindTarget = currentSampleSet }, + }, + new FormSampleSet + { + Current = { BindTarget = currentSampleSet }, + SampleAddRequested = (file, targetName) => + { + string actualFilename = string.Concat(targetName, file.Extension); + using var stream = file.OpenRead(); + beatmaps.AddFile(working.Value.BeatmapSetInfo, stream, actualFilename); + return actualFilename; + }, + SampleRemoveRequested = filename => + { + var file = working.Value.BeatmapSetInfo.GetFile(filename); + if (file != null) + beatmaps.DeleteFile(working.Value.BeatmapSetInfo, file); + } + }, }; backgroundChooser.PreviewContainer.Add(headerBackground); diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index e198d43be7..365652e6ad 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -26,6 +26,8 @@ namespace osu.Game.Skinning // 2. https://github.com/peppy/osu-stable-reference/blob/dc0994645801010d4b628fff5ff79cd3c286ca83/osu!/Graphics/Textures/TextureManager.cs#L158-L196 (user skin textures lookup) protected override bool AllowHighResolutionSprites => false; + public RealmBackedResourceStore? BeatmapSetResources => FallbackStore as RealmBackedResourceStore; + /// /// Construct a new legacy beatmap skin instance. /// diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index 0932485349..36561e0d69 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -16,6 +16,8 @@ namespace osu.Game.Skinning public class RealmBackedResourceStore : ResourceStore where T : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey { + public event Action? CacheInvalidated; + private Lazy> fileToStoragePathMapping; private readonly Live liveSource; @@ -56,7 +58,11 @@ namespace osu.Game.Skinning private string? getPathForFile(string filename) => fileToStoragePathMapping.Value.GetValueOrDefault(filename.ToLowerInvariant()); - private void invalidateCache() => fileToStoragePathMapping = new Lazy>(initialiseFileCache); + private void invalidateCache() + { + fileToStoragePathMapping = new Lazy>(initialiseFileCache); + CacheInvalidated?.Invoke(); + } private Dictionary initialiseFileCache() => liveSource.PerformRead(source => { diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 3a0d0b16e5..8c287a010e 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -63,6 +63,8 @@ namespace osu.Game.Skinning public string Name { get; } + protected IResourceStore? FallbackStore { get; } + /// /// Construct a new skin. /// @@ -102,6 +104,7 @@ namespace osu.Game.Skinning SkinInfo = skin.ToLiveUnmanaged(); } + FallbackStore = fallbackStore; if (fallbackStore != null) store.AddStore(fallbackStore); From 28a6d6211c17ba10440fe4b164dfb4e9b3a9ef8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 12:01:50 +0100 Subject: [PATCH 2/3] Manually fire editor save operations on skin change This is invariably a test fix but also a valid change to make in isolation. In short, the problem is thus: Now that the beatmap skin is hooked up to realm to receive notifications about changed files (which is required for correctly handling custom samples), tests started failing, because of the following sequence of events - Saving a brand new `.osu` to a beatmap set causes `RealmBackedResourceStore` to fire subscription callbacks because a new file was added to the set - This fires `RealmBackedResourceStore.CacheInvalidated` - Which fires `EditorBeatmapSkin.BeatmapSkinChanged` - Which would previously fire `EditorBeatmap.SaveState()` and as such mark the beatmap dirty / modified. In this scenario this is gratuitous. There's no need to be raising save states here, a new `.osu` was added to the set that is in a consistent saved state and nothing actually changed in the beatmap. However it does not appear sane to attempt to circumvent this with conditional guards or something, because in cases where files are added/removed from the set, *there isn't really any reason to take save states anyway*. The change handler only deals with the `.osu`, any modifications to any of the other files cannot be undone anyway. Therefore, only keep the state save to the one change to beatmap skin that *can* actually be sanely undone which is changing combo colours. --- osu.Game/Screens/Edit/EditorBeatmap.cs | 5 +---- osu.Game/Screens/Edit/EditorBeatmapSkin.cs | 11 +++++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 248779a9f8..48b793d1b5 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -102,10 +102,7 @@ namespace osu.Game.Screens.Edit this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; if (beatmapSkin is LegacyBeatmapSkin skin) - { - BeatmapSkin = new EditorBeatmapSkin(playableBeatmap.BeatmapInfo!.BeatmapSet!, skin); - BeatmapSkin.BeatmapSkinChanged += SaveState; - } + BeatmapSkin = new EditorBeatmapSkin(this, skin); beatmapProcessor = new EditorBeatmapProcessor(this, playableBeatmap.BeatmapInfo.Ruleset.CreateInstance()); diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index 0fbfb7b2d0..50090b7035 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Game.Audio; -using osu.Game.Beatmaps; using osu.Game.Skinning; using osuTK.Graphics; @@ -34,10 +33,13 @@ namespace osu.Game.Screens.Edit /// public BindableList ComboColours { get; } - public EditorBeatmapSkin(BeatmapSetInfo beatmapSet, LegacyBeatmapSkin skin) - { - Skin = skin; + private readonly EditorBeatmap editorBeatmap; + public EditorBeatmapSkin(EditorBeatmap editorBeatmap, LegacyBeatmapSkin skin) + { + this.editorBeatmap = editorBeatmap; + + Skin = skin; ComboColours = new BindableList(); if (Skin.Configuration.ComboColours is IReadOnlyList comboColours) @@ -67,6 +69,7 @@ namespace osu.Game.Screens.Edit for (int i = 0; i < ComboColours.Count; ++i) Skin.Configuration.CustomComboColours.Add(ComboColours[(ComboColours.Count + i - 1) % ComboColours.Count]); InvokeSkinChanged(); + editorBeatmap.SaveState(); } #endregion From affe295f50580de5fd3a7d9894f85e503cb48df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 13:02:03 +0100 Subject: [PATCH 3/3] Fix missing disposal --- osu.Game/Screens/Edit/EditorBeatmapSkin.cs | 1 + osu.Game/Skinning/Skin.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index 50090b7035..8992211ace 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -141,6 +141,7 @@ namespace osu.Game.Screens.Edit { if (Skin.BeatmapSetResources != null) Skin.BeatmapSetResources.CacheInvalidated -= InvokeSkinChanged; + Skin.Dispose(); } #region Delegated ISkin implementation diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 8c287a010e..3affd5afa6 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -342,6 +342,7 @@ namespace osu.Game.Skinning Textures?.Dispose(); Samples?.Dispose(); + FallbackStore?.Dispose(); store.Dispose(); }