From 3712093158df40b82f33bf4b75b12754baed6d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 22 Dec 2025 11:22:37 +0100 Subject: [PATCH] Add explicit menu item for exporting guest difficulties from editor A few facts of life: - Guest difficulties are at this point a staple of mapping. - People are very much used to flinging `.osu`s around (because there's no better alternative). - Currently there are two ways to get an `.osu` out of lazer. You can: - Export the beatmap as "compatibility" to an `.osz`, then transmogrify the `.osz` to a `.zip`, then extract the `.zip`, then pluck out the `.osu`. This is the "correct" way to make sure stable works, but is also stupidly arcane. - Use "edit externally" to mount the beatmap files to disk, then copy-paste out the `.osu`. This is the *wrong* way to make sure stable works, because the mounting process exposes the raw "for editing" format with features stable doesn't support, but it the actual easy one. - Reports about guest difficulties exported from lazer "working wrong on stable" are prevalent. Probably mostly because of the preceding point. What this PR does is introduce a *third* method to export an `.osu`, which is designed to be both the easiest one yet *and* correct. I am hoping this will curb the complaints until support for direct submission of guest difficulties is added - which I still hope to see, but it will be a significant effort *client-side* (the server side has been ready for years now). And yes, you will notice that much of the code added in `LegacyBeatmapExporter` related to manipulation of the path is copy-pasted from `LegacyExporter`. I don't care enough to invent protected / abstract / whatever else OOP faff for something that may not survive review and is mostly a weird semi-temporary wart. --- osu.Game/Beatmaps/BeatmapManager.cs | 6 ++- osu.Game/Database/LegacyBeatmapExporter.cs | 54 ++++++++++++++++++++++ osu.Game/Database/LegacyExporter.cs | 14 +++--- osu.Game/Localisation/EditorStrings.cs | 5 ++ osu.Game/Screens/Edit/Editor.cs | 19 +++----- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 08a611e320..62c03b4fcd 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -476,9 +476,11 @@ namespace osu.Game.Beatmaps public Task> BeginExternalEditing(BeatmapSetInfo model) => beatmapImporter.BeginExternalEditing(model); - public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm)); + public Task Export(BeatmapSetInfo beatmapSet) => beatmapExporter.ExportAsync(beatmapSet.ToLive(Realm)); - public Task ExportLegacy(BeatmapSetInfo beatmap) => legacyBeatmapExporter.ExportAsync(beatmap.ToLive(Realm)); + public Task ExportLegacy(BeatmapSetInfo beatmapSet) => legacyBeatmapExporter.ExportAsync(beatmapSet.ToLive(Realm)); + + public Task ExportLegacy(BeatmapInfo beatmap) => legacyBeatmapExporter.ExportAsync(beatmap.ToLive(Realm)); private void updateHashAndMarkDirty(BeatmapSetInfo setInfo) { diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index 8d90c9adb4..7587c2332f 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -2,17 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; +using System.Threading.Tasks; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Timing; +using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; +using osu.Game.Utils; using osuTK; namespace osu.Game.Database @@ -175,5 +180,54 @@ namespace osu.Game.Database } protected override string FileExtension => @".osz"; + + public Task ExportAsync(Live beatmap) => Task.Run(() => + { + string itemFilename = Path.GetFileNameWithoutExtension(beatmap.PerformRead(s => s.File!.Filename.GetValidFilename())); + const string osu_extension = @".osu"; + + if (itemFilename.Length > MAX_FILENAME_LENGTH - osu_extension.Length) + itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - osu_extension.Length); + + IEnumerable existingExports = ExportStorage + .GetFiles(string.Empty, $"{itemFilename}*{osu_extension}") + .Concat(ExportStorage.GetDirectories(string.Empty)); + + string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{osu_extension}"); + + ProgressNotification notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = $"Exporting {itemFilename}...", + }; + + PostNotification?.Invoke(notification); + + try + { + beatmap.PerformRead(b => + { + using var exportStream = ExportStorage.CreateFileSafely(filename); + using var inputFile = GetFileContents(b.BeatmapSet!, b.File!); + + if (inputFile == null) + throw new InvalidOperationException($"Beatmap file {b.File!.Filename} could not be opened!"); + + inputFile.CopyTo(exportStream); + }); + } + catch + { + notification.State = ProgressNotificationState.Cancelled; + + // cleanup if export is failed or canceled. + ExportStorage.Delete(filename); + throw; + } + + notification.CompletionText = $"Exported {itemFilename}! Click to view."; + notification.CompletionClickAction = () => ExportStorage.PresentFileExternally(filename); + notification.State = ProgressNotificationState.Completed; + }); } } diff --git a/osu.Game/Database/LegacyExporter.cs b/osu.Game/Database/LegacyExporter.cs index 80393c27f7..cc0fd8f8a2 100644 --- a/osu.Game/Database/LegacyExporter.cs +++ b/osu.Game/Database/LegacyExporter.cs @@ -41,13 +41,13 @@ namespace osu.Game.Database protected abstract string FileExtension { get; } protected readonly Storage UserFileStorage; - private readonly Storage exportStorage; + protected readonly Storage ExportStorage; public Action? PostNotification { get; set; } protected LegacyExporter(Storage storage) { - exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); + ExportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); UserFileStorage = storage.GetStorageForDirectory(@"files"); } @@ -74,9 +74,9 @@ namespace osu.Game.Database if (itemFilename.Length > MAX_FILENAME_LENGTH - FileExtension.Length) itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - FileExtension.Length); - IEnumerable existingExports = exportStorage + IEnumerable existingExports = ExportStorage .GetFiles(string.Empty, $"{itemFilename}*{FileExtension}") - .Concat(exportStorage.GetDirectories(string.Empty)); + .Concat(ExportStorage.GetDirectories(string.Empty)); string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}"); @@ -92,7 +92,7 @@ namespace osu.Game.Database try { - using (var stream = exportStorage.CreateFileSafely(filename)) + using (var stream = ExportStorage.CreateFileSafely(filename)) { await ExportToStreamAsync(model, stream, notification, linkedSource.Token).ConfigureAwait(false); } @@ -102,12 +102,12 @@ namespace osu.Game.Database notification.State = ProgressNotificationState.Cancelled; // cleanup if export is failed or canceled. - exportStorage.Delete(filename); + ExportStorage.Delete(filename); throw; } notification.CompletionText = $"Exported {itemFilename}! Click to view."; - notification.CompletionClickAction = () => exportStorage.PresentFileExternally(filename); + notification.CompletionClickAction = () => ExportStorage.PresentFileExternally(filename); notification.State = ProgressNotificationState.Completed; } diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index c8b163c678..a9e151d3e5 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -54,6 +54,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ExportForCompatibility => new TranslatableString(getKey(@"export_for_compatibility"), @"For compatibility (.osz)"); + /// + /// "Guest difficulty (.osu)" + /// + public static LocalisableString ExportGuestDifficulty => new TranslatableString(getKey(@"export_guest_difficulty"), @"Guest difficulty (.osu)"); + /// /// "Create new difficulty" /// diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index d40d36530a..03a0942e19 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -1329,8 +1329,9 @@ namespace osu.Game.Screens.Edit { var exportItems = new List { - new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => exportBeatmap(false)), - new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => exportBeatmap(true)), + new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => runExport(manager => manager.Export(Beatmap.Value.BeatmapSetInfo))), + new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => runExport(manager => manager.ExportLegacy(Beatmap.Value.BeatmapSetInfo))), + new EditorMenuItem(EditorStrings.ExportGuestDifficulty, MenuItemType.Standard, () => runExport(manager => manager.ExportLegacy(Beatmap.Value.BeatmapInfo))), }; return new EditorMenuItem(CommonStrings.Export) { Items = exportItems }; @@ -1396,7 +1397,7 @@ namespace osu.Game.Screens.Edit void startSubmission() => this.Push(new BeatmapSubmissionScreen()); } - private void exportBeatmap(bool legacy) + private void runExport(Func exportAction) { if (HasUnsavedChanges) { @@ -1405,20 +1406,12 @@ namespace osu.Game.Screens.Edit if (!Save()) return Task.CompletedTask; - return runExport(); + return exportAction.Invoke(beatmapManager); }))); } else { - attemptAsyncMutationOperation(runExport); - } - - Task runExport() - { - if (legacy) - return beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo); - else - return beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); + attemptAsyncMutationOperation(() => exportAction(beatmapManager)); } }