From 19551b476aaf7c9f11a657a66f830d7092d2a142 Mon Sep 17 00:00:00 2001 From: maotovisk Date: Fri, 27 Mar 2026 04:26:15 -0300 Subject: [PATCH] fix(map_cleaner): rework map cleaner --- MapWizard.CLI/MapWizard.CLI.csproj | 2 +- MapWizard.CLI/Program.cs | 18 +- MapWizard.Desktop/MapWizard.Desktop.csproj | 12 +- .../MapCleanerService/IMapCleanerService.cs | 1 - .../MapCleanerService/MapCleanerService.cs | 25 - .../ViewModels/MapCleanerViewModel.cs | 24 +- .../Views/MapCleaner/MapCleanerView.axaml | 36 +- MapWizard.Tests/MapCleaner/MapCleanerTests.cs | 638 ++++++++++++--- .../MapCleaner/StableSnapEngineTests.cs | 138 ++++ MapWizard.Tests/MapWizard.Tests.csproj | 4 +- .../MapCleaner/Analysis/MapCleanerAnalyzer.cs | 235 ------ MapWizard.Tools/MapCleaner/MapCleaner.cs | 773 +++++++++++------- .../MapCleaner/MapCleanerAnalysis.cs | 9 - .../MapCleaner/MapCleanerOptions.cs | 14 +- .../MapCleaner/MapCleanerResult.cs | 20 +- .../MapCleaner/Snapping/StableSnapEngine.cs | 23 +- .../MapCleaner/Timing/InheritedPruner.cs | 120 --- .../Timing/TimingInfluenceRebuilder.cs | 97 --- MapWizard.Tools/MapWizard.Tools.csproj | 2 +- 19 files changed, 1214 insertions(+), 977 deletions(-) create mode 100644 MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs delete mode 100644 MapWizard.Tools/MapCleaner/Analysis/MapCleanerAnalyzer.cs delete mode 100644 MapWizard.Tools/MapCleaner/MapCleanerAnalysis.cs delete mode 100644 MapWizard.Tools/MapCleaner/Timing/InheritedPruner.cs delete mode 100644 MapWizard.Tools/MapCleaner/Timing/TimingInfluenceRebuilder.cs diff --git a/MapWizard.CLI/MapWizard.CLI.csproj b/MapWizard.CLI/MapWizard.CLI.csproj index 19ad54c..74802f8 100644 --- a/MapWizard.CLI/MapWizard.CLI.csproj +++ b/MapWizard.CLI/MapWizard.CLI.csproj @@ -5,7 +5,7 @@ - + diff --git a/MapWizard.CLI/Program.cs b/MapWizard.CLI/Program.cs index d94bd09..9fb9d16 100644 --- a/MapWizard.CLI/Program.cs +++ b/MapWizard.CLI/Program.cs @@ -78,16 +78,9 @@ static void Main(string[] args) var options = new MapCleanerOptions { SnapDivisors = ParseCsv(snapsRaw), - AnalyzeSamples = !args.ArgumentExists("--skip-analysis"), - ResnapObjects = !args.ArgumentExists("--no-resnap-objects"), - ResnapSliderEnds = !args.ArgumentExists("--no-resnap-slider-ends"), - ResnapGreenLines = !args.ArgumentExists("--no-resnap-greenlines"), - ResnapBookmarks = args.ArgumentExists("--resnap-bookmarks"), - RemoveUnusedInheritedTimingPoints = !args.ArgumentExists("--keep-inherited"), - RemoveHitSounds = args.ArgumentExists("--remove-hitsounds"), - RemoveUnusedSamples = args.ArgumentExists("--remove-unused-samples"), + ResnapEverything = !args.ArgumentExists("--no-resnap"), RemoveMuting = args.ArgumentExists("--remove-muting"), - MuteUnclickableHitsounds = args.ArgumentExists("--mute-unclickable-hitsounds") + RemoveUnusedGreenlines = args.ArgumentExists("--remove-unused-greenlines") }; RunMapCleaner(targetPaths, options); @@ -106,9 +99,10 @@ private static void RunMapCleaner(string[] targetPaths, MapCleanerOptions option { Console.WriteLine( $"Map cleaner finished. Cleaned {result.ProcessedBeatmaps} beatmap(s): " + - $"{result.ObjectsResnapped} objects resnapped, " + - $"{result.GreenLinesResnapped} greenlines resnapped, " + - $"{result.InheritedTimingPointsRemoved} greenlines removed."); + $"{result.TimingPointsResnapped} timing points resnapped, " + + $"{result.ObjectsResnapped} object starts resnapped, " + + $"{result.SliderEndsResnapped} slider ends resnapped, " + + $"{result.GreenLinesRemoved} greenlines removed."); return; } diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj index 0902999..5727297 100644 --- a/MapWizard.Desktop/MapWizard.Desktop.csproj +++ b/MapWizard.Desktop/MapWizard.Desktop.csproj @@ -18,15 +18,15 @@ - - - + + + - + - - + + diff --git a/MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs b/MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs index 0fb82a3..83fc838 100644 --- a/MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs +++ b/MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs @@ -4,6 +4,5 @@ namespace MapWizard.Desktop.Services.MapCleanerService; public interface IMapCleanerService { - public bool TryAnalyzeMap(string beatmapPath, out MapCleanerAnalysis analysis); public bool CleanMaps(string[] targetPaths, MapCleanerOptions options, out MapCleanerBatchResult result); } diff --git a/MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs b/MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs index c2bd0b4..07be38a 100644 --- a/MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs +++ b/MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs @@ -1,35 +1,10 @@ using System; -using System.IO; -using BeatmapParser; using MapWizard.Tools.MapCleaner; namespace MapWizard.Desktop.Services.MapCleanerService; public class MapCleanerService : IMapCleanerService { - public bool TryAnalyzeMap(string beatmapPath, out MapCleanerAnalysis analysis) - { - analysis = new MapCleanerAnalysis(); - - try - { - if (string.IsNullOrWhiteSpace(beatmapPath) || !File.Exists(beatmapPath)) - { - return false; - } - - var beatmap = Beatmap.Decode(File.ReadAllText(beatmapPath)); - analysis = MapCleaner.AnalyzeBeatmap(beatmap); - return true; - } - catch (Exception ex) - { - MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); - Console.WriteLine(ex.Message); - return false; - } - } - public bool CleanMaps(string[] targetPaths, MapCleanerOptions options, out MapCleanerBatchResult result) { result = new MapCleanerBatchResult(); diff --git a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs index e82db27..947391c 100644 --- a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs @@ -34,16 +34,9 @@ public partial class MapCleanerViewModel( { [ObservableProperty] private SelectedMap _originBeatmap = new(); - [ObservableProperty] private bool _analyzeSamples = true; - [ObservableProperty] private bool _resnapObjects = true; - [ObservableProperty] private bool _resnapSliderEnds = true; - [ObservableProperty] private bool _resnapGreenLines = true; - [ObservableProperty] private bool _resnapBookmarks; - [ObservableProperty] private bool _removeHitSounds; - [ObservableProperty] private bool _removeUnusedSamples; + [ObservableProperty] private bool _resnapEverything = true; [ObservableProperty] private bool _removeMuting; - [ObservableProperty] private bool _muteUnclickableHitsounds; - [ObservableProperty] private bool _removeUnusedInheritedTimingPoints = true; + [ObservableProperty] private bool _removeUnusedGreenlines; [ObservableProperty] private string _customSnapInput = string.Empty; @@ -224,16 +217,9 @@ private void CleanMaps() var options = new MapCleanerOptions { - AnalyzeSamples = AnalyzeSamples, - ResnapObjects = ResnapObjects, - ResnapSliderEnds = ResnapSliderEnds, - ResnapGreenLines = ResnapGreenLines, - ResnapBookmarks = ResnapBookmarks, - RemoveUnusedInheritedTimingPoints = RemoveUnusedInheritedTimingPoints, - RemoveHitSounds = RemoveHitSounds, - RemoveUnusedSamples = RemoveUnusedSamples, + ResnapEverything = ResnapEverything, RemoveMuting = RemoveMuting, - MuteUnclickableHitsounds = MuteUnclickableHitsounds, + RemoveUnusedGreenlines = RemoveUnusedGreenlines, SnapDivisors = ActiveSnapDivisors.ToList() }; @@ -244,7 +230,7 @@ private void CleanMaps() toastManager.ShowToast( NotificationType.Success, "Map Cleaner", - $"Done. Resnapped {result.ObjectsResnapped} objects, {result.SliderEndsResnapped} slider ends, and {result.GreenLinesResnapped} greenlines; removed {result.InheritedTimingPointsRemoved} greenlines."); + $"Done. Resnapped {result.TimingPointsResnapped} timing points, {result.ObjectsResnapped} object starts, {result.SliderEndsResnapped} slider ends, {result.BookmarksResnapped} bookmarks, and {result.PreviewTimeResnapped} preview points; removed {result.GreenLinesRemoved} greenlines."); LoadOriginBeatmapHeader(); return; diff --git a/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml b/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml index b515a55..29edef9 100644 --- a/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml +++ b/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml @@ -181,44 +181,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - + + diff --git a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs index e605225..fa3dd11 100644 --- a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs +++ b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs @@ -1,6 +1,7 @@ using BeatmapParser; using BeatmapParser.HitObjects; using BeatmapParser.TimingPoints; +using MapWizard.Tools.HitSounds.Copier; using MapWizard.Tools.MapCleaner; namespace MapWizard.Tests.MapCleaner; @@ -8,122 +9,351 @@ namespace MapWizard.Tests.MapCleaner; public class MapCleanerTests { [Fact] - public void CleanBeatmap_UnsnapedObject_ResnapsToNearestTick() + public void CleanBeatmap_WithAllOptionsDisabled_DoesNotMutateBeatmap() { - // Arrange - var beatmap = Beatmap.Decode(GetSimpleResnapBeatmap()); - var firstObject = beatmap.HitObjects.Objects[0]; + var beatmap = Beatmap.Decode(GetResnapEverythingBeatmap()); + var encodedBefore = beatmap.Encode(); - var options = new MapCleanerOptions + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions { - ResnapObjects = true, - RemoveUnusedInheritedTimingPoints = false, - SnapDivisors = ["1/2", "1/4", "1/8"] - }; + ResnapEverything = false, + RemoveMuting = false, + RemoveUnusedGreenlines = false + }); + + Assert.Equal(encodedBefore, beatmap.Encode()); + Assert.Equal(0, result.TimingPointsResnapped); + Assert.Equal(0, result.ObjectsResnapped); + Assert.Equal(0, result.SliderEndsResnapped); + Assert.Equal(0, result.SpinnerOrHoldEndsResnapped); + Assert.Equal(0, result.BookmarksResnapped); + Assert.Equal(0, result.PreviewTimeResnapped); + Assert.Equal(0, result.GreenLinesResnapped); + Assert.Equal(0, result.GreenLinesRemoved); + Assert.Equal(0, result.MutedTimingPointsRestored); + } - // Act - var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, options); + [Fact] + public void CleanBeatmap_ResnapEverything_ResnapsTimingPointsObjectsBookmarksAndPreview() + { + var beatmap = Beatmap.Decode(GetResnapEverythingBeatmap()); + var greenline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + var secondRedline = beatmap.TimingPoints.TimingPointList.OfType().Last(); + var circle = beatmap.HitObjects.Objects.OfType().Single(); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); - // Assert - Assert.Equal(TimeSpan.FromMilliseconds(125), firstObject.Time); - Assert.True(result.ObjectsResnapped >= 1); + Assert.Equal(TimeSpan.FromMilliseconds(125), circle.Time); + Assert.Equal(TimeSpan.FromMilliseconds(125), greenline.Time); + Assert.Equal(TimeSpan.FromMilliseconds(625), secondRedline.Time); + Assert.NotNull(beatmap.Editor); + var editor = beatmap.Editor!; + Assert.NotNull(editor.Bookmarks); + Assert.Equal(TimeSpan.FromMilliseconds(125), editor.Bookmarks[0]); + Assert.Equal(125, beatmap.GeneralSection.PreviewTime); + Assert.Equal(2, result.TimingPointsResnapped); + Assert.Equal(1, result.GreenLinesResnapped); + Assert.Equal(1, result.ObjectsResnapped); + Assert.Equal(1, result.BookmarksResnapped); + Assert.Equal(1, result.PreviewTimeResnapped); } [Fact] - public void CleanBeatmap_ObjectWithinTenMillisecondsAfterRedline_UsesFutureRedlineForSnap() + public void CleanBeatmap_ResnapEverything_ResnapsSpinnerEnd() { - // Arrange - var beatmap = Beatmap.Decode(GetFutureRedlineTestBeatmap()); - var targetObject = beatmap.HitObjects.Objects[0]; + var beatmap = Beatmap.Decode(GetSpinnerBeatmap()); + var spinner = beatmap.HitObjects.Objects.OfType().Single(); - var options = new MapCleanerOptions + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions { - ResnapObjects = true, - RemoveUnusedInheritedTimingPoints = false, - AnalyzeSamples = false, + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, SnapDivisors = ["1/4"] - }; + }); + + Assert.Equal(TimeSpan.FromMilliseconds(0), spinner.Time); + Assert.Equal(TimeSpan.FromMilliseconds(125), spinner.End); + Assert.Equal(1, result.ObjectsResnapped); + Assert.Equal(1, result.SpinnerOrHoldEndsResnapped); + } - // Act - MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, options); + [Fact] + public void CleanBeatmap_ResnapEverything_ResnapsHoldEnd() + { + var beatmap = Beatmap.Decode(GetManiaHoldBeatmap()); + var hold = beatmap.HitObjects.Objects.OfType().Single(); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); - // Assert - Assert.Equal(TimeSpan.FromMilliseconds(1005), targetObject.Time); + Assert.Equal(TimeSpan.FromMilliseconds(0), hold.Time); + Assert.Equal(TimeSpan.FromMilliseconds(125), hold.End); + Assert.Equal(1, result.ObjectsResnapped); + Assert.Equal(1, result.SpinnerOrHoldEndsResnapped); } [Fact] - public void CleanBeatmap_UnusedInheritedTimingPoint_RemovesIt() + public void CleanBeatmap_ResnapEverything_ResnapsSliderByFullDuration() { - // Arrange - var beatmap = Beatmap.Decode(GetUnusedInheritedTimingPointBeatmap()); - Assert.NotNull(beatmap.TimingPoints); - var beforeInheritedCount = beatmap.TimingPoints.TimingPointList.Count(x => x is InheritedTimingPoint); + var beatmap = Beatmap.Decode(GetTripleRepeatSliderBeatmap()); + var slider = beatmap.HitObjects.Objects.OfType().Single(); - var options = new MapCleanerOptions + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions { - ResnapObjects = false, - AnalyzeSamples = false, - RemoveUnusedInheritedTimingPoints = true - }; - - // Act - var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, options); - - // Assert - var afterInheritedCount = beatmap.TimingPoints.TimingPointList.Count(x => x is InheritedTimingPoint); - Assert.Equal(beforeInheritedCount - 1, afterInheritedCount); - Assert.Equal(1, result.InheritedTimingPointsRemoved); + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + Assert.Equal(TimeSpan.FromMilliseconds(0), slider.Time); + Assert.Equal(TimeSpan.FromMilliseconds(250), slider.EndTime); + Assert.InRange(slider.Length, 23.332, 23.334); + Assert.Equal(1, result.ObjectsResnapped); + Assert.Equal(1, result.SliderEndsResnapped); } [Fact] - public void CleanBeatmap_UnsnapedGreenline_ResnapsIt() + public void CleanBeatmap_ResnapEverything_ResnapsRedlinesRelativeToPreviousReferenceRedline() { - // Arrange - var beatmap = Beatmap.Decode(GetGreenlineResnapBeatmap()); - Assert.NotNull(beatmap.TimingPoints); - var greenline = beatmap.TimingPoints.TimingPointList.OfType().First(); + var beatmap = Beatmap.Decode(GetRelativeRedlineBeatmap()); - var options = new MapCleanerOptions + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions { - ResnapObjects = false, - ResnapGreenLines = true, - RemoveUnusedInheritedTimingPoints = false, + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, SnapDivisors = ["1/4"] - }; + }); - // Act - var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, options); + var redlines = beatmap.TimingPoints!.TimingPointList + .OfType() + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); - // Assert - Assert.Equal(TimeSpan.FromMilliseconds(125), greenline.Time); - Assert.Equal(1, result.GreenLinesResnapped); + Assert.Equal(TimeSpan.FromMilliseconds(0), redlines[0].Time); + Assert.Equal(TimeSpan.FromMilliseconds(375), redlines[1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(878), redlines[2].Time); + Assert.Equal(2, result.TimingPointsResnapped); } [Fact] - public void CleanBeatmap_ReverseSlider_ResnapsByFirstSlideDuration() + public void CleanBeatmap_RemoveMuting_RestoresMutedTimingPoints() { - // Arrange - var beatmap = Beatmap.Decode(GetReverseSliderResnapBeatmap()); - var slider = beatmap.HitObjects.Objects.OfType().Single(); + var beatmap = Beatmap.Decode(GetMutedTimingPointBeatmap()); - var options = new MapCleanerOptions + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions { - ResnapObjects = true, - RemoveUnusedInheritedTimingPoints = false, - SnapDivisors = ["1/4"] - }; + ResnapEverything = false, + RemoveMuting = true, + RemoveUnusedGreenlines = false + }); + + var timingPoints = beatmap.TimingPoints!.TimingPointList.OrderBy(x => x.Time.TotalMilliseconds).ToList(); + Assert.Equal((uint)70, timingPoints[0].Volume); + Assert.Equal((uint)70, timingPoints[1].Volume); + Assert.Equal((uint)70, timingPoints[2].Volume); + Assert.Equal((uint)80, timingPoints[3].Volume); + Assert.Equal(2, result.MutedTimingPointsRestored); + } - // Act - var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, options); + [Fact] + public void CleanBeatmap_RemoveUnusedGreenlines_RemovesUnusedAndRedundantGreenlines() + { + var beatmap = Beatmap.Decode(GetGreenlinePruningBeatmap()); - // Assert - Assert.Equal((uint)2, slider.Slides); - Assert.Equal(TimeSpan.FromMilliseconds(250), slider.EndTime); - Assert.InRange(slider.Length, 34.999, 35.001); - Assert.Equal(1, result.SliderEndsResnapped); + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = false, + RemoveMuting = false, + RemoveUnusedGreenlines = true + }); + + var inheritedTimingPoints = beatmap.TimingPoints!.TimingPointList.OfType().ToList(); + Assert.Single(inheritedTimingPoints); + Assert.Equal(TimeSpan.FromMilliseconds(400), inheritedTimingPoints[0].Time); + Assert.Equal(2, result.GreenLinesRemoved); + } + + [Fact] + public void CleanBeatmap_RemoveUnusedGreenlines_KeepsGreenlineThatAffectsSliderBody() + { + var beatmap = Beatmap.Decode(GetSliderBodyGreenlineBeatmap()); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = false, + RemoveMuting = false, + RemoveUnusedGreenlines = true + }); + + var inheritedTimingPoints = beatmap.TimingPoints!.TimingPointList + .OfType() + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + + Assert.Single(inheritedTimingPoints); + Assert.Equal(TimeSpan.FromMilliseconds(200), inheritedTimingPoints[0].Time); + Assert.Equal(2.0, inheritedTimingPoints[0].SliderVelocity, precision: 3); + Assert.Equal(2, result.GreenLinesRemoved); + } + + [Fact] + public void CleanBeatmapTargets_MissingFile_ReportsFailure() + { + var missingPath = Path.Combine(Path.GetTempPath(), "mapwizard-mapcleaner-missing-" + Guid.NewGuid().ToString("N") + ".osu"); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmapTargets([missingPath], new MapCleanerOptions()); + + Assert.Equal(0, result.ProcessedBeatmaps); + Assert.Equal(1, result.FailedBeatmaps); + Assert.Contains(missingPath, result.FailedPaths); + Assert.Single(result.FailureDetails); + } + + [Fact] + public void CleanBeatmapTargets_ValidFile_WritesCleanedBeatmapCreatesBackupAndAggregatesCounts() + { + var sandboxRoot = CreateSandbox("mapwizard-mapcleaner-targets"); + var previousXdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + Environment.SetEnvironmentVariable("XDG_DATA_HOME", sandboxRoot); + + try + { + var beatmapPath = Path.Combine(sandboxRoot, "target.osu"); + File.WriteAllText(beatmapPath, GetResnapEverythingBeatmap()); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmapTargets([beatmapPath], new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + var cleanedBeatmap = Beatmap.Decode(File.ReadAllText(beatmapPath)); + var cleanedCircle = cleanedBeatmap.HitObjects.Objects.OfType().Single(); + var backupDirectory = Path.Combine(sandboxRoot, "MapWizard", "Backup"); + + Assert.Equal(1, result.ProcessedBeatmaps); + Assert.Equal(0, result.FailedBeatmaps); + Assert.Equal(2, result.TimingPointsResnapped); + Assert.Equal(1, result.ObjectsResnapped); + Assert.Equal(1, result.BookmarksResnapped); + Assert.Equal(1, result.PreviewTimeResnapped); + Assert.Equal(1, result.GreenLinesResnapped); + Assert.Equal(TimeSpan.FromMilliseconds(125), cleanedCircle.Time); + Assert.True(Directory.Exists(backupDirectory)); + Assert.Single(Directory.GetFiles(backupDirectory, "*.osu")); + } + finally + { + Environment.SetEnvironmentVariable("XDG_DATA_HOME", previousXdgDataHome); + Directory.Delete(sandboxRoot, recursive: true); + } + } + + [Fact] + public void HitSoundCopierThenMapCleaner_DoesNotTurnSilentSliderTailIntoHeadHitsound() + { + var source = Beatmap.Decode(GetHeadOnlySliderSourceBeatmap()); + var target = Beatmap.Decode(GetHeadOnlySliderTargetBeatmap()); + + var copied = global::MapWizard.Tools.HitSounds.Copier.HitSoundCopier.CopyFromBeatmap(source, target, new HitSoundCopierOptions + { + CopySliderBodySounds = false, + OverwriteEverything = true + }); + + var copiedSlider = copied.HitObjects.Objects.OfType().Single(); + Assert.Single(copiedSlider.HeadSounds.Sounds); + Assert.Contains(BeatmapParser.Enums.HitSound.Whistle, copiedSlider.HeadSounds.Sounds); + Assert.Empty(copiedSlider.TailSounds.Sounds); + + var reloaded = Beatmap.Decode(copied.Encode()); + MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(reloaded, new MapCleanerOptions + { + ResnapEverything = false, + RemoveMuting = false, + RemoveUnusedGreenlines = false + }); + + var finalSlider = reloaded.HitObjects.Objects.OfType().Single(); + Assert.Single(finalSlider.HeadSounds.Sounds); + Assert.Contains(BeatmapParser.Enums.HitSound.Whistle, finalSlider.HeadSounds.Sounds); + var finalTailSound = Assert.Single(finalSlider.TailSounds.Sounds); + Assert.Equal(BeatmapParser.Enums.HitSound.None, finalTailSound); + } + + private static string GetResnapEverythingBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: 126 + Countdown: 0 + SampleSet: Normal + StackLeniency: 0.7 + Mode: 0 + LetterboxInBreaks: 0 + WidescreenStoryboard: 0 + + [Editor] + DistanceSpacing: 1 + BeatDivisor: 4 + GridSize: 4 + TimelineZoom: 1 + Bookmarks: 126 + + [Metadata] + Title: t + TitleUnicode: t + Artist: a + ArtistUnicode: a + Creator: c + Version: test + Source: + Tags: + BeatmapID: 0 + BeatmapSetID: -1 + + [Difficulty] + HPDrainRate: 5 + CircleSize: 4 + OverallDifficulty: 8 + ApproachRate: 9 + SliderMultiplier: 1.4 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 0,500,4,1,0,100,1,0 + 101,-100,4,1,0,100,0,0 + 626,500,4,1,0,100,1,0 + + [HitObjects] + 256,192,126,1,0,0:0:0:0: + """.Replace("\n", "\r\n"); } - private static string GetFutureRedlineTestBeatmap() + private static string GetTripleRepeatSliderBeatmap() { return """ osu file format v14 @@ -144,7 +374,6 @@ osu file format v14 BeatDivisor: 4 GridSize: 4 TimelineZoom: 1 - Bookmarks: 1001 [Metadata] Title: t @@ -171,14 +400,13 @@ osu file format v14 [TimingPoints] 0,500,4,1,0,100,1,0 - 1005,400,4,1,0,100,1,0 [HitObjects] - 256,192,1001,1,0,0:0:0:0: + 256,192,3,2,0,B|356:192,3,28 """.Replace("\n", "\r\n"); } - private static string GetSimpleResnapBeatmap() + private static string GetSpinnerBeatmap() { return """ osu file format v14 @@ -227,11 +455,64 @@ osu file format v14 0,500,4,1,0,100,1,0 [HitObjects] - 256,192,126,1,0,0:0:0:0: + 256,192,3,8,0,126,0:0:0:0: """.Replace("\n", "\r\n"); } - private static string GetUnusedInheritedTimingPointBeatmap() + private static string GetManiaHoldBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: -1 + Countdown: 0 + SampleSet: Normal + StackLeniency: 0.7 + Mode: 3 + LetterboxInBreaks: 0 + WidescreenStoryboard: 0 + + [Editor] + DistanceSpacing: 1 + BeatDivisor: 4 + GridSize: 4 + TimelineZoom: 1 + + [Metadata] + Title: t + TitleUnicode: t + Artist: a + ArtistUnicode: a + Creator: c + Version: test + Source: + Tags: + BeatmapID: 0 + BeatmapSetID: -1 + + [Difficulty] + HPDrainRate: 5 + CircleSize: 4 + OverallDifficulty: 8 + ApproachRate: 9 + SliderMultiplier: 1.4 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 0,500,4,1,0,100,1,0 + + [HitObjects] + 64,192,3,128,0,126:0:0:0:0: + """.Replace("\n", "\r\n"); + } + + private static string GetRelativeRedlineBeatmap() { return """ osu file format v14 @@ -278,15 +559,191 @@ osu file format v14 [TimingPoints] 0,500,4,1,0,100,1,0 - 100,-100,4,1,0,60,0,0 - 200,-100,4,1,0,70,0,0 + 378,400,4,1,0,100,1,0 + 876,400,4,1,0,100,1,0 [HitObjects] - 256,192,300,1,0,0:0:0:0: + 256,192,0,1,0,0:0:0:0: """.Replace("\n", "\r\n"); } - private static string GetGreenlineResnapBeatmap() + private static string GetMutedTimingPointBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: -1 + Countdown: 0 + SampleSet: Normal + StackLeniency: 0.7 + Mode: 0 + LetterboxInBreaks: 0 + WidescreenStoryboard: 0 + + [Editor] + DistanceSpacing: 1 + BeatDivisor: 4 + GridSize: 4 + TimelineZoom: 1 + + [Metadata] + Title: t + TitleUnicode: t + Artist: a + ArtistUnicode: a + Creator: c + Version: test + Source: + Tags: + BeatmapID: 0 + BeatmapSetID: -1 + + [Difficulty] + HPDrainRate: 5 + CircleSize: 4 + OverallDifficulty: 8 + ApproachRate: 9 + SliderMultiplier: 1.4 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 0,500,4,1,0,70,1,0 + 100,-100,4,1,0,5,0,0 + 200,-100,4,1,0,4,0,0 + 300,500,4,1,0,80,1,0 + + [HitObjects] + 256,192,0,1,0,0:0:0:0: + """.Replace("\n", "\r\n"); + } + + private static string GetGreenlinePruningBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: -1 + Countdown: 0 + SampleSet: Normal + StackLeniency: 0.7 + Mode: 0 + LetterboxInBreaks: 0 + WidescreenStoryboard: 0 + + [Editor] + DistanceSpacing: 1 + BeatDivisor: 4 + GridSize: 4 + TimelineZoom: 1 + + [Metadata] + Title: t + TitleUnicode: t + Artist: a + ArtistUnicode: a + Creator: c + Version: test + Source: + Tags: + BeatmapID: 0 + BeatmapSetID: -1 + + [Difficulty] + HPDrainRate: 5 + CircleSize: 4 + OverallDifficulty: 8 + ApproachRate: 9 + SliderMultiplier: 1.4 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 0,500,4,1,0,100,1,0 + 100,-100,4,1,0,100,0,0 + 200,-50,4,1,0,100,0,0 + 300,500,4,1,0,100,1,0 + 400,-50,4,1,0,100,0,0 + + [HitObjects] + 256,192,350,2,0,B|356:192,1,84 + """.Replace("\n", "\r\n"); + } + + private static string GetSliderBodyGreenlineBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: -1 + Countdown: 0 + SampleSet: Normal + StackLeniency: 0.7 + Mode: 0 + LetterboxInBreaks: 0 + WidescreenStoryboard: 0 + + [Editor] + DistanceSpacing: 1 + BeatDivisor: 4 + GridSize: 4 + TimelineZoom: 1 + + [Metadata] + Title: t + TitleUnicode: t + Artist: a + ArtistUnicode: a + Creator: c + Version: test + Source: + Tags: + BeatmapID: 0 + BeatmapSetID: -1 + + [Difficulty] + HPDrainRate: 5 + CircleSize: 4 + OverallDifficulty: 8 + ApproachRate: 9 + SliderMultiplier: 1.4 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 0,500,4,1,0,100,1,0 + 100,-100,4,1,0,100,0,0 + 200,-50,4,1,0,100,0,0 + 600,-25,4,1,0,100,0,0 + + [HitObjects] + 256,192,150,2,0,B|356:192,1,84 + """.Replace("\n", "\r\n"); + } + + private static string CreateSandbox(string prefix) + { + var path = Path.Combine(Path.GetTempPath(), prefix + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static string GetHeadOnlySliderSourceBeatmap() { return """ osu file format v14 @@ -333,14 +790,13 @@ osu file format v14 [TimingPoints] 0,500,4,1,0,100,1,0 - 101,-100,4,1,0,60,0,0 [HitObjects] - 256,192,300,1,0,0:0:0:0: + 256,192,0,1,2,0:0:0:0: """.Replace("\n", "\r\n"); } - private static string GetReverseSliderResnapBeatmap() + private static string GetHeadOnlySliderTargetBeatmap() { return """ osu file format v14 @@ -389,7 +845,7 @@ osu file format v14 0,500,4,1,0,100,1,0 [HitObjects] - 256,192,0,2,0,B|356:192,2,50.4 + 256,192,0,2,0,B|356:192,1,84 """.Replace("\n", "\r\n"); } } diff --git a/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs b/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs new file mode 100644 index 0000000..c6b7502 --- /dev/null +++ b/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs @@ -0,0 +1,138 @@ +using BeatmapParser; +using MapWizard.Tools.MapCleaner.Snapping; + +namespace MapWizard.Tests.MapCleaner; + +public class StableSnapEngineTests +{ + [Fact] + public void ParseDivisors_WithOnlyInvalidValues_FallsBackToDefaultDivisors() + { + var divisors = StableSnapEngine.ParseDivisors(["", "abc", "1", "0/4", "2/0"]); + + Assert.Collection(divisors, + divisor => + { + Assert.Equal(1, divisor.Numerator); + Assert.Equal(8, divisor.Denominator); + }, + divisor => + { + Assert.Equal(1, divisor.Numerator); + Assert.Equal(12, divisor.Denominator); + }); + } + + [Fact] + public void ParseDivisors_RemovesDuplicatesAndSortsByDenominatorThenNumerator() + { + var divisors = StableSnapEngine.ParseDivisors(["1/12", "1/8", "1/12", "3/16", "1/16"]); + + Assert.Collection(divisors, + divisor => + { + Assert.Equal(1, divisor.Numerator); + Assert.Equal(8, divisor.Denominator); + }, + divisor => + { + Assert.Equal(1, divisor.Numerator); + Assert.Equal(12, divisor.Denominator); + }, + divisor => + { + Assert.Equal(1, divisor.Numerator); + Assert.Equal(16, divisor.Denominator); + }, + divisor => + { + Assert.Equal(3, divisor.Numerator); + Assert.Equal(16, divisor.Denominator); + }); + } + + [Theory] + [InlineData(1.49, 1)] + [InlineData(1.5, 2)] + [InlineData(-1.49, -1)] + [InlineData(-1.5, -2)] + public void StableRound_MatchesStableRoundingBehavior(double value, int expected) + { + Assert.Equal(expected, StableSnapEngine.StableRound(value)); + } + + [Fact] + public void SnapRelativeMilliseconds_WhenEquidistant_PrefersEarlierCandidate() + { + var snapped = StableSnapEngine.SnapRelativeMilliseconds(62.5, 0, 500, [new SnapDivisor(1, 4)]); + + Assert.Equal(0, snapped); + } + + [Fact] + public void SnapMilliseconds_UsesForwardRedlineWhenWithinWindow() + { + var beatmap = Beatmap.Decode(GetForwardWindowBeatmap()); + var divisors = StableSnapEngine.ParseDivisors(["1/4"]); + + var withForwardWindow = StableSnapEngine.SnapMilliseconds(985, beatmap.TimingPoints, divisors, forwardRedlineWindowMs: 10); + var withoutForwardWindow = StableSnapEngine.SnapMilliseconds(985, beatmap.TimingPoints, divisors, forwardRedlineWindowMs: 4); + + Assert.Equal(990, withForwardWindow); + Assert.Equal(1000, withoutForwardWindow); + } + + private static string GetForwardWindowBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: -1 + Countdown: 0 + SampleSet: Normal + StackLeniency: 0.7 + Mode: 0 + LetterboxInBreaks: 0 + WidescreenStoryboard: 0 + + [Editor] + DistanceSpacing: 1 + BeatDivisor: 4 + GridSize: 4 + TimelineZoom: 1 + + [Metadata] + Title: t + TitleUnicode: t + Artist: a + ArtistUnicode: a + Creator: c + Version: test + Source: + Tags: + BeatmapID: 0 + BeatmapSetID: -1 + + [Difficulty] + HPDrainRate: 5 + CircleSize: 4 + OverallDifficulty: 8 + ApproachRate: 9 + SliderMultiplier: 1.4 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 0,500,4,1,0,100,1,0 + 990,500,4,1,0,100,1,0 + + [HitObjects] + 256,192,0,1,0,0:0:0:0: + """.Replace("\n", "\r\n"); + } +} diff --git a/MapWizard.Tests/MapWizard.Tests.csproj b/MapWizard.Tests/MapWizard.Tests.csproj index 1648f03..0980351 100644 --- a/MapWizard.Tests/MapWizard.Tests.csproj +++ b/MapWizard.Tests/MapWizard.Tests.csproj @@ -20,14 +20,14 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/MapWizard.Tools/MapCleaner/Analysis/MapCleanerAnalyzer.cs b/MapWizard.Tools/MapCleaner/Analysis/MapCleanerAnalyzer.cs deleted file mode 100644 index 239ba1f..0000000 --- a/MapWizard.Tools/MapCleaner/Analysis/MapCleanerAnalyzer.cs +++ /dev/null @@ -1,235 +0,0 @@ -using BeatmapParser; -using BeatmapParser.HitObjects; -using BeatmapParser.TimingPoints; - -namespace MapWizard.Tools.MapCleaner.Analysis; - -public static class MapCleanerAnalyzer -{ - public static MapCleanerAnalysis Analyze(Beatmap beatmap) - { - var analysis = new MapCleanerAnalysis - { - BeatDivisorSignature = BuildBeatDivisorSignature(beatmap) - }; - - if (beatmap.TimingPoints == null || beatmap.TimingPoints.TimingPointList.Count == 0) - { - return analysis; - } - - var timingPoints = beatmap.TimingPoints.TimingPointList - .OrderBy(x => x.Time.TotalMilliseconds) - .ToList(); - - foreach (var slider in beatmap.HitObjects.Objects.OfType()) - { - AnalyzeRange( - timingPoints, - slider.Time.TotalMilliseconds, - slider.EndTime.TotalMilliseconds, - out var hasVolumeChange, - out var hasSampleSetChange); - - if (hasVolumeChange) - { - analysis.UsesSliderVolumeChanges = true; - } - - if (hasSampleSetChange) - { - analysis.UsesSliderSampleSetChanges = true; - } - - if (analysis.UsesSliderVolumeChanges && analysis.UsesSliderSampleSetChanges) - { - break; - } - } - - foreach (var spinner in beatmap.HitObjects.Objects.OfType()) - { - AnalyzeRange( - timingPoints, - spinner.Time.TotalMilliseconds, - spinner.End.TotalMilliseconds, - out var hasVolumeChange, - out _); - - if (hasVolumeChange) - { - analysis.UsesSpinnerVolumeChanges = true; - break; - } - } - - if (!analysis.UsesSpinnerVolumeChanges) - { - foreach (var hold in beatmap.HitObjects.Objects.OfType()) - { - AnalyzeRange( - timingPoints, - hold.Time.TotalMilliseconds, - hold.End.TotalMilliseconds, - out var hasVolumeChange, - out _); - - if (!hasVolumeChange) - { - continue; - } - - analysis.UsesSpinnerVolumeChanges = true; - break; - } - } - - return analysis; - } - - private static string BuildBeatDivisorSignature(Beatmap beatmap) - { - if (beatmap.TimingPoints == null || beatmap.TimingPoints.TimingPointList.Count == 0) - { - return string.Empty; - } - - var redlines = beatmap.TimingPoints.TimingPointList - .OfType() - .OrderBy(x => x.Time.TotalMilliseconds) - .ToList(); - - if (redlines.Count == 0) - { - return string.Empty; - } - - var usedDivisors = new HashSet(); - - foreach (var hitObject in beatmap.HitObjects.Objects) - { - RegisterDivisor(hitObject.Time.TotalMilliseconds, redlines, usedDivisors); - - switch (hitObject) - { - case Slider slider: - RegisterDivisor(slider.EndTime.TotalMilliseconds, redlines, usedDivisors); - break; - case Spinner spinner: - RegisterDivisor(spinner.End.TotalMilliseconds, redlines, usedDivisors); - break; - case ManiaHold hold: - RegisterDivisor(hold.End.TotalMilliseconds, redlines, usedDivisors); - break; - } - } - - if (usedDivisors.Count == 0) - { - return string.Empty; - } - - return string.Join(",", usedDivisors.OrderBy(x => x).Select(x => $"1/{x}")); - } - - private static void RegisterDivisor(double timeMs, IReadOnlyList redlines, ISet usedDivisors) - { - var redline = redlines[0]; - foreach (var timingPoint in redlines) - { - if (timingPoint.Time.TotalMilliseconds > timeMs) - { - break; - } - - redline = timingPoint; - } - - var beatLength = Math.Abs(redline.BeatLength); - if (beatLength <= 0.00001) - { - return; - } - - var relative = (timeMs - redline.Time.TotalMilliseconds) / beatLength; - var fraction = Math.Abs(relative - Math.Floor(relative)); - - if (fraction < 0.0005 || Math.Abs(1.0 - fraction) < 0.0005) - { - return; - } - - for (var denominator = 2; denominator <= 16; denominator++) - { - var scaled = fraction * denominator; - var nearest = Math.Round(scaled, MidpointRounding.AwayFromZero); - if (Math.Abs(scaled - nearest) > 0.005) - { - continue; - } - - usedDivisors.Add(denominator); - break; - } - } - - private static void AnalyzeRange( - IReadOnlyList orderedTimingPoints, - double start, - double end, - out bool hasVolumeChange, - out bool hasSampleSetChange) - { - hasVolumeChange = false; - hasSampleSetChange = false; - - for (var i = 0; i < orderedTimingPoints.Count; i++) - { - var timingPoint = orderedTimingPoints[i]; - var timingPointMs = timingPoint.Time.TotalMilliseconds; - - if (timingPointMs <= start || timingPointMs >= end) - { - continue; - } - - var previous = FindPreviousTimingPoint(orderedTimingPoints, i, timingPointMs); - if (previous == null) - { - continue; - } - - if (timingPoint.Volume != previous.Volume) - { - hasVolumeChange = true; - } - - if (timingPoint.SampleSet != previous.SampleSet || timingPoint.SampleIndex != previous.SampleIndex) - { - hasSampleSetChange = true; - } - - if (hasVolumeChange && hasSampleSetChange) - { - return; - } - } - } - - private static TimingPoint? FindPreviousTimingPoint( - IReadOnlyList orderedTimingPoints, - int currentIndex, - double timeMs) - { - for (var i = currentIndex - 1; i >= 0; i--) - { - var timingPoint = orderedTimingPoints[i]; - if (timingPoint.Time.TotalMilliseconds <= timeMs) - { - return timingPoint; - } - } - - return null; - } -} diff --git a/MapWizard.Tools/MapCleaner/MapCleaner.cs b/MapWizard.Tools/MapCleaner/MapCleaner.cs index e80cf52..7a2a473 100644 --- a/MapWizard.Tools/MapCleaner/MapCleaner.cs +++ b/MapWizard.Tools/MapCleaner/MapCleaner.cs @@ -1,188 +1,28 @@ using BeatmapParser; using BeatmapParser.HitObjects; -using BeatmapParser.HitObjects.HitSounds; using BeatmapParser.TimingPoints; using MapWizard.Tools.HelperExtensions; -using MapWizard.Tools.MapCleaner.Analysis; using MapWizard.Tools.MapCleaner.Snapping; -using MapWizard.Tools.MapCleaner.Timing; namespace MapWizard.Tools.MapCleaner; public static class MapCleaner { - public static MapCleanerAnalysis AnalyzeBeatmap(Beatmap beatmap) - { - return MapCleanerAnalyzer.Analyze(beatmap); - } - public static MapCleanerResult CleanBeatmap(Beatmap beatmap, MapCleanerOptions? options = null) { options ??= new MapCleanerOptions(); var result = new MapCleanerResult(); var divisors = StableSnapEngine.ParseDivisors(options.SnapDivisors); + var referenceBeatmap = Beatmap.Decode(beatmap.Encode()); - var analysis = options.AnalyzeSamples ? MapCleanerAnalyzer.Analyze(beatmap) : new MapCleanerAnalysis(); - result.Analysis = analysis; - - if (options.ResnapGreenLines) + if (options.ResnapEverything) { - result.GreenLinesResnapped += ResnapGreenLines(beatmap, divisors, options.RedlineLookaheadForObjectsMs); - } - - if (options.ResnapObjects) - { - foreach (var hitObject in beatmap.HitObjects.Objects) - { - var originalStart = hitObject.Time.TotalMilliseconds; - var snappedStart = StableSnapEngine.SnapMilliseconds( - originalStart, - beatmap.TimingPoints, - divisors, - options.RedlineLookaheadForObjectsMs); - - if (Math.Abs(snappedStart - originalStart) > 0.0001) - { - hitObject.Time = TimeSpan.FromMilliseconds(snappedStart); - result.ObjectsResnapped++; - } - - switch (hitObject) - { - case Slider slider: - { - var originalEnd = slider.EndTime.TotalMilliseconds; - var originalDurationMs = Math.Max(1.0, originalEnd - originalStart); - var originalLength = slider.Length; - var slideCount = Math.Max(1, (int)slider.Slides); - var originalSlideDurationMs = Math.Max(1.0, originalDurationMs / slideCount); - - if (options.ResnapSliderEnds) - { - var baselineFirstSlideEndMs = slider.Time.TotalMilliseconds + originalSlideDurationMs; - var currentFirstSlideEndMs = originalStart + originalSlideDurationMs; - var snappedFromBaseline = StableSnapEngine.SnapMilliseconds( - baselineFirstSlideEndMs, - beatmap.TimingPoints, - divisors, - options.RedlineLookaheadForEndsMs); - var snappedFromCurrent = StableSnapEngine.SnapMilliseconds( - currentFirstSlideEndMs, - beatmap.TimingPoints, - divisors, - options.RedlineLookaheadForEndsMs); - - var snappedFirstSlideEnd = Math.Abs(snappedFromBaseline - baselineFirstSlideEndMs) <= Math.Abs(snappedFromCurrent - baselineFirstSlideEndMs) - ? snappedFromBaseline - : snappedFromCurrent; - - var minimumFirstSlideEnd = slider.Time.TotalMilliseconds + 1; - if (snappedFirstSlideEnd < minimumFirstSlideEnd) - { - snappedFirstSlideEnd = (int)minimumFirstSlideEnd; - } - - var snappedSlideDurationMs = Math.Max(1.0, snappedFirstSlideEnd - slider.Time.TotalMilliseconds); - var snappedEnd = StableSnapEngine.StableRound(slider.Time.TotalMilliseconds + (snappedSlideDurationMs * slideCount)); - - var minimumEnd = slider.Time.TotalMilliseconds + 1; - if (snappedEnd < minimumEnd) - { - snappedEnd = StableSnapEngine.StableRound(minimumEnd); - } - - if (Math.Abs(snappedEnd - originalEnd) > 0.0001) - { - ApplySliderEndResnap( - beatmap, - slider, - snappedEnd, - originalLength, - originalDurationMs); - result.SliderEndsResnapped++; - } - } - - break; - } - case Spinner spinner: - { - var originalEnd = spinner.End.TotalMilliseconds; - var snappedEnd = StableSnapEngine.SnapMilliseconds( - originalEnd, - beatmap.TimingPoints, - divisors, - options.RedlineLookaheadForEndsMs); - - var minimumEnd = spinner.Time.TotalMilliseconds + 1; - if (snappedEnd < minimumEnd) - { - snappedEnd = (int)minimumEnd; - } - - if (Math.Abs(snappedEnd - originalEnd) > 0.0001) - { - spinner.End = TimeSpan.FromMilliseconds(snappedEnd); - result.SpinnerOrHoldEndsResnapped++; - } - - break; - } - case ManiaHold maniaHold: - { - var originalEnd = maniaHold.End.TotalMilliseconds; - var snappedEnd = StableSnapEngine.SnapMilliseconds( - originalEnd, - beatmap.TimingPoints, - divisors, - options.RedlineLookaheadForEndsMs); - - var minimumEnd = maniaHold.Time.TotalMilliseconds + 1; - if (snappedEnd < minimumEnd) - { - snappedEnd = (int)minimumEnd; - } - - if (Math.Abs(snappedEnd - originalEnd) > 0.0001) - { - maniaHold.End = TimeSpan.FromMilliseconds(snappedEnd); - result.SpinnerOrHoldEndsResnapped++; - } - - break; - } - } - } - } - - if (options.ResnapBookmarks && beatmap.Editor?.Bookmarks != null) - { - var bookmarks = beatmap.Editor.Bookmarks; - for (var i = 0; i < bookmarks.Count; i++) - { - var originalBookmark = bookmarks[i].TotalMilliseconds; - var snappedBookmark = StableSnapEngine.SnapMilliseconds( - originalBookmark, - beatmap.TimingPoints, - divisors, - options.RedlineLookaheadForObjectsMs); - - if (snappedBookmark == originalBookmark) - { - continue; - } - - bookmarks[i] = TimeSpan.FromMilliseconds(snappedBookmark); - result.BookmarksResnapped++; - } - - beatmap.Editor.Bookmarks = bookmarks; - } - - if (options.RemoveHitSounds) - { - result.HitSoundsRemoved += RemoveHitSounds(beatmap); + result.TimingPointsResnapped += ResnapTimingPoints(beatmap, referenceBeatmap, divisors, options.ForwardRedlineWindowMs, ref result.GreenLinesResnapped); + result.ObjectsResnapped += ResnapHitObjectStartTimes(beatmap, referenceBeatmap, divisors, options.ForwardRedlineWindowMs); + ResnapHitObjectEndsAndSliderLengths(beatmap, referenceBeatmap, divisors, options, result); + result.BookmarksResnapped += ResnapBookmarks(beatmap, referenceBeatmap, divisors, options.ForwardRedlineWindowMs); + result.PreviewTimeResnapped += ResnapPreviewTime(beatmap, referenceBeatmap, divisors, options.ForwardRedlineWindowMs); } if (options.RemoveMuting) @@ -190,21 +30,9 @@ public static MapCleanerResult CleanBeatmap(Beatmap beatmap, MapCleanerOptions? result.MutedTimingPointsRestored += RemoveMuting(beatmap); } - if (options.MuteUnclickableHitsounds) + if (options.RemoveUnusedGreenlines) { - result.UnclickableHitSoundsMuted += MuteUnclickableHitSounds(beatmap); - } - - if (options.RemoveUnusedInheritedTimingPoints) - { - // Run the analyzer if analysis was skipped but pruning is requested. - if (!options.AnalyzeSamples) - { - analysis = MapCleanerAnalyzer.Analyze(beatmap); - result.Analysis = analysis; - } - - result.InheritedTimingPointsRemoved += InheritedPruner.PruneUnusedInheritedTimingPoints(beatmap, analysis); + result.GreenLinesRemoved += RemoveUnusedGreenlines(beatmap); } return result; @@ -233,7 +61,6 @@ public static MapCleanerBatchResult CleanBeatmapTargets(string[] targetPaths, Ma var result = CleanBeatmap(beatmap, options); BeatmapBackupHelper.CreateBackupCopy(targetPath); - File.WriteAllText(targetPath, beatmap.Encode().Replace("\r\n", "\n").Replace("\n", "\r\n")); batchResult.ProcessedBeatmaps++; @@ -251,57 +78,257 @@ public static MapCleanerBatchResult CleanBeatmapTargets(string[] targetPaths, Ma return batchResult; } - private static int ResnapGreenLines(Beatmap beatmap, IReadOnlyList divisors, int lookaheadMs) + private static int ResnapTimingPoints( + Beatmap beatmap, + Beatmap referenceBeatmap, + IReadOnlyList divisors, + int forwardRedlineWindowMs, + ref int greenLinesResnapped) { - if (beatmap.TimingPoints == null || beatmap.TimingPoints.TimingPointList.Count == 0) + if (beatmap.TimingPoints == null || referenceBeatmap.TimingPoints == null) { return 0; } - var resnapped = 0; - foreach (var greenLine in beatmap.TimingPoints.TimingPointList.OfType()) + var timingPointsResnapped = 0; + var referenceRedlines = referenceBeatmap.TimingPoints.TimingPointList + .OfType() + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + + UninheritedTimingPoint? previousReferenceRedline = null; + + foreach (var timingPoint in beatmap.TimingPoints.TimingPointList) + { + var originalTime = timingPoint.Time.TotalMilliseconds; + int snappedTime; + + if (timingPoint is UninheritedTimingPoint) + { + if (previousReferenceRedline == null) + { + previousReferenceRedline = referenceRedlines.FirstOrDefault(); + snappedTime = StableSnapEngine.StableRound(originalTime); + } + else + { + snappedTime = StableSnapEngine.SnapRelativeMilliseconds( + originalTime, + previousReferenceRedline.Time.TotalMilliseconds, + previousReferenceRedline.BeatLength, + divisors); + } + + var matchingReference = referenceRedlines + .LastOrDefault(x => x.Time.TotalMilliseconds <= originalTime); + + if (matchingReference != null) + { + previousReferenceRedline = matchingReference; + } + } + else + { + snappedTime = StableSnapEngine.SnapMilliseconds( + originalTime, + referenceBeatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + } + + if (Math.Abs(snappedTime - originalTime) <= 0.0001) + { + continue; + } + + timingPoint.Time = TimeSpan.FromMilliseconds(snappedTime); + timingPointsResnapped++; + + if (timingPoint is InheritedTimingPoint) + { + greenLinesResnapped++; + } + } + + SortTimingPoints(beatmap); + return timingPointsResnapped; + } + + private static int ResnapHitObjectStartTimes( + Beatmap beatmap, + Beatmap referenceBeatmap, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + if (referenceBeatmap.TimingPoints == null) + { + return 0; + } + + var objectsResnapped = 0; + + foreach (var hitObject in beatmap.HitObjects.Objects) { - var originalTime = greenLine.Time.TotalMilliseconds; + var originalTime = hitObject.Time.TotalMilliseconds; var snappedTime = StableSnapEngine.SnapMilliseconds( originalTime, - beatmap.TimingPoints, + referenceBeatmap.TimingPoints, divisors, - lookaheadMs); + forwardRedlineWindowMs); if (Math.Abs(snappedTime - originalTime) <= 0.0001) { continue; } - greenLine.Time = TimeSpan.FromMilliseconds(snappedTime); - resnapped++; + hitObject.Time = TimeSpan.FromMilliseconds(snappedTime); + objectsResnapped++; } - if (resnapped > 0) + return objectsResnapped; + } + + private static void ResnapHitObjectEndsAndSliderLengths( + Beatmap beatmap, + Beatmap referenceBeatmap, + IReadOnlyList divisors, + MapCleanerOptions options, + MapCleanerResult result) + { + if (referenceBeatmap.TimingPoints == null) { - SortTimingPoints(beatmap); + return; } - return resnapped; + var referenceObjects = referenceBeatmap.HitObjects.Objects; + + for (var index = 0; index < beatmap.HitObjects.Objects.Count; index++) + { + var hitObject = beatmap.HitObjects.Objects[index]; + var referenceObject = index < referenceObjects.Count ? referenceObjects[index] : null; + + switch (hitObject) + { + case Slider slider when referenceObject is Slider referenceSlider: + if (ResnapSliderEndAndLength(beatmap, referenceBeatmap, slider, referenceSlider, divisors, options)) + { + result.SliderEndsResnapped++; + } + break; + case Spinner spinner when referenceObject is Spinner referenceSpinner: + if (ResnapSpinnerEnd(referenceBeatmap, spinner, referenceSpinner, divisors, options.ForwardRedlineWindowMs)) + { + result.SpinnerOrHoldEndsResnapped++; + } + break; + case ManiaHold maniaHold when referenceObject is ManiaHold referenceHold: + if (ResnapHoldEnd(referenceBeatmap, maniaHold, referenceHold, divisors, options.ForwardRedlineWindowMs)) + { + result.SpinnerOrHoldEndsResnapped++; + } + break; + } + } } - private static void ApplySliderEndResnap( + private static bool ResnapSliderEndAndLength( Beatmap beatmap, + Beatmap referenceBeatmap, Slider slider, - int snappedEndMs, - double originalLength, - double originalDurationMs) + Slider referenceSlider, + IReadOnlyList divisors, + MapCleanerOptions options) { - var startMs = slider.Time.TotalMilliseconds; - var duration = Math.Max(1, snappedEndMs - startMs); + var originalStart = referenceSlider.Time.TotalMilliseconds; + var originalEnd = referenceSlider.EndTime.TotalMilliseconds; + var originalDuration = Math.Max(1.0, originalEnd - originalStart); var slides = Math.Max(1, (int)slider.Slides); + var snappedStart = slider.Time.TotalMilliseconds; - var sliderMultiplier = Math.Abs(beatmap.DifficultySection.SliderMultiplier); - if (sliderMultiplier <= 0.00001) + var targetEnd = snappedStart + originalDuration; + var snappedEnd = StableSnapEngine.SnapMilliseconds( + targetEnd, + referenceBeatmap.TimingPoints, + divisors, + options.ForwardRedlineWindowMs); + + if (snappedEnd <= snappedStart) { - sliderMultiplier = 1.0; + snappedEnd = StableSnapEngine.StableRound(snappedStart + 1); + } + + var endChanged = Math.Abs(snappedEnd - slider.EndTime.TotalMilliseconds) > 0.0001; + var newDuration = Math.Max(1.0, snappedEnd - snappedStart); + var newLength = CalculateSliderLengthFromDuration(beatmap, slider, newDuration, slides); + var lengthChanged = Math.Abs(newLength - slider.Length) > 0.0001; + + slider.EndTime = TimeSpan.FromMilliseconds(snappedEnd); + slider.Length = newLength; + + return endChanged || lengthChanged; + } + + private static bool ResnapSpinnerEnd( + Beatmap referenceBeatmap, + Spinner spinner, + Spinner referenceSpinner, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + var originalDuration = Math.Max(1.0, referenceSpinner.End.TotalMilliseconds - referenceSpinner.Time.TotalMilliseconds); + var targetEnd = spinner.Time.TotalMilliseconds + originalDuration; + var snappedEnd = StableSnapEngine.SnapMilliseconds( + targetEnd, + referenceBeatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + + if (snappedEnd <= spinner.Time.TotalMilliseconds) + { + snappedEnd = StableSnapEngine.StableRound(spinner.Time.TotalMilliseconds + 1); + } + + if (Math.Abs(snappedEnd - spinner.End.TotalMilliseconds) <= 0.0001) + { + return false; + } + + spinner.End = TimeSpan.FromMilliseconds(snappedEnd); + return true; + } + + private static bool ResnapHoldEnd( + Beatmap referenceBeatmap, + ManiaHold maniaHold, + ManiaHold referenceHold, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + var originalDuration = Math.Max(1.0, referenceHold.End.TotalMilliseconds - referenceHold.Time.TotalMilliseconds); + var targetEnd = maniaHold.Time.TotalMilliseconds + originalDuration; + var snappedEnd = StableSnapEngine.SnapMilliseconds( + targetEnd, + referenceBeatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + + if (snappedEnd <= maniaHold.Time.TotalMilliseconds) + { + snappedEnd = StableSnapEngine.StableRound(maniaHold.Time.TotalMilliseconds + 1); + } + + if (Math.Abs(snappedEnd - maniaHold.End.TotalMilliseconds) <= 0.0001) + { + return false; } + maniaHold.End = TimeSpan.FromMilliseconds(snappedEnd); + return true; + } + + private static double CalculateSliderLengthFromDuration(Beatmap beatmap, Slider slider, double durationMs, int slides) + { + var startMs = slider.Time.TotalMilliseconds; var uninheritedTimingPoint = beatmap.GetUninheritedTimingPointAt(startMs); var beatLength = Math.Abs(uninheritedTimingPoint?.BeatLength ?? 0); if (beatLength <= 0.00001) @@ -316,166 +343,322 @@ private static void ApplySliderEndResnap( sliderVelocity = 1.0; } - var recalculatedLength = (duration * sliderMultiplier * 100.0 * sliderVelocity) / (beatLength * slides); - var fallbackLength = originalLength * (duration / Math.Max(1.0, originalDurationMs)); + var sliderMultiplier = Math.Abs(beatmap.DifficultySection.SliderMultiplier); + if (sliderMultiplier <= 0.00001) + { + sliderMultiplier = 1.0; + } - if (double.IsFinite(fallbackLength) && fallbackLength > 0.00001) + var length = (durationMs * sliderMultiplier * 100.0 * sliderVelocity) / (beatLength * slides); + return double.IsFinite(length) && length > 0.00001 ? length : slider.Length; + } + + private static int ResnapBookmarks( + Beatmap beatmap, + Beatmap referenceBeatmap, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + if (beatmap.Editor?.Bookmarks == null || referenceBeatmap.TimingPoints == null) { - var upperGuard = Math.Max(fallbackLength * 4.0, originalLength * 4.0); - var lowerGuard = Math.Min(fallbackLength * 0.25, Math.Max(0.00001, originalLength * 0.25)); + return 0; + } + + var resnapped = 0; + var bookmarks = beatmap.Editor.Bookmarks; - if (!double.IsFinite(recalculatedLength) || recalculatedLength > upperGuard || recalculatedLength < lowerGuard) + for (var i = 0; i < bookmarks.Count; i++) + { + var originalTime = bookmarks[i].TotalMilliseconds; + var snappedTime = StableSnapEngine.SnapMilliseconds( + originalTime, + referenceBeatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + + if (Math.Abs(snappedTime - originalTime) <= 0.0001) { - recalculatedLength = fallbackLength; + continue; } + + bookmarks[i] = TimeSpan.FromMilliseconds(snappedTime); + resnapped++; } - if (double.IsFinite(recalculatedLength) && recalculatedLength > 0.00001) + beatmap.Editor.Bookmarks = bookmarks; + return resnapped; + } + + private static int ResnapPreviewTime( + Beatmap beatmap, + Beatmap referenceBeatmap, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + if (beatmap.GeneralSection.PreviewTime is not int previewTime || previewTime < 0 || referenceBeatmap.TimingPoints == null) { - slider.Length = recalculatedLength; + return 0; } - slider.EndTime = TimeSpan.FromMilliseconds(snappedEndMs); + var snappedPreviewTime = StableSnapEngine.SnapMilliseconds( + previewTime, + referenceBeatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + + if (snappedPreviewTime == previewTime) + { + return 0; + } + + beatmap.GeneralSection.PreviewTime = snappedPreviewTime; + return 1; } - private static void SortTimingPoints(Beatmap beatmap) + private static int RemoveMuting(Beatmap beatmap) { if (beatmap.TimingPoints == null) { - return; + return 0; } - beatmap.TimingPoints.TimingPointList = beatmap.TimingPoints.TimingPointList + var restored = 0; + var fallbackVolume = 100u; + + foreach (var timingPoint in beatmap.TimingPoints.TimingPointList.OrderBy(x => x.Time.TotalMilliseconds)) + { + if (timingPoint.Volume <= 5) + { + timingPoint.Volume = fallbackVolume; + restored++; + continue; + } + + fallbackVolume = timingPoint.Volume; + } + + return restored; + } + + private static int RemoveUnusedGreenlines(Beatmap beatmap) + { + if (beatmap.TimingPoints == null) + { + return 0; + } + + SortTimingPoints(beatmap); + var timingPoints = beatmap.TimingPoints.TimingPointList; + var inheritedTimingPoints = timingPoints.OfType().ToList(); + if (inheritedTimingPoints.Count == 0) + { + return 0; + } + + var usedGreenlines = GetUsedInheritedTimingPoints(beatmap, inheritedTimingPoints); + var toRemove = new HashSet(); + + foreach (var inheritedTimingPoint in inheritedTimingPoints) + { + if (!usedGreenlines.Contains(inheritedTimingPoint)) + { + toRemove.Add(inheritedTimingPoint); + } + } + + var orderedTimingPoints = timingPoints .Select((timingPoint, index) => new IndexedTimingPoint(index, timingPoint)) .OrderBy(x => x.TimingPoint.Time.TotalMilliseconds) .ThenBy(x => x.TimingPoint is UninheritedTimingPoint ? 0 : 1) .ThenBy(x => x.Index) - .Select(x => x.TimingPoint) .ToList(); - } - - private sealed record IndexedTimingPoint(int Index, TimingPoint TimingPoint); - private static int RemoveHitSounds(Beatmap beatmap) - { - var removed = 0; + InheritedState? activeState = null; + UninheritedTimingPoint? activeRedline = null; - foreach (var hitObject in beatmap.HitObjects.Objects) + foreach (var (_, timingPoint) in orderedTimingPoints) { - switch (hitObject) + switch (timingPoint) { - case Circle circle: - { - if (!HasAnyHitSoundData(circle.HitSounds)) - { - break; - } - - circle.HitSounds = (new HitSample(), []); - removed++; + case UninheritedTimingPoint redline: + activeRedline = redline; + activeState = BuildBaselineInheritedState(redline); break; - } - case Spinner spinner: - { - if (!HasAnyHitSoundData(spinner.HitSounds)) + case InheritedTimingPoint greenline: + var currentState = BuildInheritedState(greenline, activeRedline); + if (activeState != null && currentState.Equals(activeState)) { - break; + toRemove.Add(greenline); } - - spinner.HitSounds = (new HitSample(), []); - removed++; - break; - } - case ManiaHold maniaHold: - { - if (!HasAnyHitSoundData(maniaHold.HitSounds)) + else { - break; + activeState = currentState; } - - maniaHold.HitSounds = (new HitSample(), []); - removed++; break; - } + } + } + + if (toRemove.Count == 0) + { + return 0; + } + + beatmap.TimingPoints.TimingPointList = timingPoints + .Where(x => !toRemove.Contains(x)) + .ToList(); + SortTimingPoints(beatmap); + return toRemove.Count; + } + + private static HashSet GetUsedInheritedTimingPoints( + Beatmap beatmap, + IReadOnlyList inheritedTimingPoints) + { + var used = new HashSet(); + + foreach (var hitObject in beatmap.HitObjects.Objects) + { + MarkActiveInheritedPointAt(beatmap, hitObject.Time.TotalMilliseconds, used); + + switch (hitObject) + { case Slider slider: - { - var hadHitSounds = HasAnyHitSoundData(slider.HitSounds) || - HasAnyHitSoundData(slider.HeadSounds) || - HasAnyHitSoundData(slider.TailSounds) || - (slider.RepeatSounds != null && slider.RepeatSounds.Any(HasAnyHitSoundData)); + MarkActiveInheritedPointAt(beatmap, slider.EndTime.TotalMilliseconds, used); - if (!hadHitSounds) + var slides = Math.Max(1, (int)slider.Slides); + for (var i = 1; i < slides; i++) { - break; + var repeatTime = slider.Time.TotalMilliseconds + ((slider.EndTime.TotalMilliseconds - slider.Time.TotalMilliseconds) * i / slides); + MarkActiveInheritedPointAt(beatmap, repeatTime, used); } - slider.HitSounds = (new HitSample(), []); - slider.HeadSounds = (new HitSample(), []); - slider.TailSounds = (new HitSample(), []); - - if (slider.RepeatSounds != null) + foreach (var inheritedTimingPoint in inheritedTimingPoints) { - for (var i = 0; i < slider.RepeatSounds.Count; i++) + var timeMs = inheritedTimingPoint.Time.TotalMilliseconds; + if (timeMs > slider.Time.TotalMilliseconds && timeMs <= slider.EndTime.TotalMilliseconds) { - slider.RepeatSounds[i] = (new HitSample(), []); + used.Add(inheritedTimingPoint); } } - - removed++; break; - } + case Spinner spinner: + MarkActiveInheritedPointAt(beatmap, spinner.End.TotalMilliseconds, used); + break; + case ManiaHold maniaHold: + MarkActiveInheritedPointAt(beatmap, maniaHold.End.TotalMilliseconds, used); + break; } } - return removed; + return used; } - private static int RemoveMuting(Beatmap beatmap) + private static void MarkActiveInheritedPointAt(Beatmap beatmap, double timeMs, ISet used) { if (beatmap.TimingPoints == null) { - return 0; + return; } - var restored = 0; - var fallbackVolume = 100u; - - foreach (var timingPoint in beatmap.TimingPoints.TimingPointList.OrderBy(x => x.Time.TotalMilliseconds)) + InheritedTimingPoint? activeInherited = null; + foreach (var timingPoint in beatmap.TimingPoints.TimingPointList + .OrderBy(x => x.Time.TotalMilliseconds) + .ThenBy(x => x is UninheritedTimingPoint ? 0 : 1)) { - if (timingPoint.Volume <= 5) + if (timingPoint.Time.TotalMilliseconds > timeMs) { - timingPoint.Volume = fallbackVolume; - restored++; - continue; + break; } - fallbackVolume = timingPoint.Volume; + switch (timingPoint) + { + case UninheritedTimingPoint: + activeInherited = null; + break; + case InheritedTimingPoint inheritedTimingPoint: + activeInherited = inheritedTimingPoint; + break; + } } - return restored; + if (activeInherited != null) + { + used.Add(activeInherited); + } + } + + private static InheritedState BuildBaselineInheritedState(UninheritedTimingPoint? redline) + { + return new InheritedState( + sampleSet: redline?.SampleSet ?? 0, + sampleIndex: redline?.SampleIndex ?? 0, + volume: redline?.Volume ?? 100, + sliderVelocity: 1.0, + effectsSignature: GetEffectsSignature(redline?.Effects)); } - private static int MuteUnclickableHitSounds(Beatmap beatmap) + private static InheritedState BuildInheritedState(InheritedTimingPoint greenline, UninheritedTimingPoint? activeRedline) { - var muted = 0; + return new InheritedState( + sampleSet: greenline.SampleSet != 0 ? greenline.SampleSet : activeRedline?.SampleSet ?? 0, + sampleIndex: greenline.SampleIndex != 0 ? greenline.SampleIndex : activeRedline?.SampleIndex ?? 0, + volume: greenline.Volume != 0 ? greenline.Volume : activeRedline?.Volume ?? 100, + sliderVelocity: greenline.SliderVelocity, + effectsSignature: GetEffectsSignature(greenline.Effects)); + } - foreach (var slider in beatmap.HitObjects.Objects.OfType()) + private static string GetEffectsSignature(IEnumerable? effects) + { + if (effects == null) { - if (!HasAnyHitSoundData(slider.HitSounds)) - { - continue; - } + return string.Empty; + } + + return string.Join(",", effects.OrderBy(x => (int)x).Select(x => x.ToString())); + } - slider.HitSounds = (new HitSample(), []); - muted++; + private static void SortTimingPoints(Beatmap beatmap) + { + if (beatmap.TimingPoints == null) + { + return; } - return muted; + beatmap.TimingPoints.TimingPointList = beatmap.TimingPoints.TimingPointList + .Select((timingPoint, index) => new IndexedTimingPoint(index, timingPoint)) + .OrderBy(x => x.TimingPoint.Time.TotalMilliseconds) + .ThenBy(x => x.TimingPoint is UninheritedTimingPoint ? 0 : 1) + .ThenBy(x => x.Index) + .Select(x => x.TimingPoint) + .ToList(); } - private static bool HasAnyHitSoundData((HitSample SampleData, List HitSounds) hitSoundSet) + private sealed record IndexedTimingPoint(int Index, TimingPoint TimingPoint); + + private sealed record InheritedState( + BeatmapParser.Enums.SampleSet sampleSet, + uint sampleIndex, + uint volume, + double sliderVelocity, + string effectsSignature) { - var (sampleData, hitSounds) = hitSoundSet; - return hitSounds.Count > 0 || !string.IsNullOrWhiteSpace(sampleData.FileName); + public bool Equals(InheritedState? other) + { + if (other is null) + { + return false; + } + + return sampleSet == other.sampleSet && + sampleIndex == other.sampleIndex && + volume == other.volume && + Math.Abs(sliderVelocity - other.sliderVelocity) <= 0.0005 && + effectsSignature == other.effectsSignature; + } + + public override int GetHashCode() + { + return HashCode.Combine(sampleSet, sampleIndex, volume, Math.Round(sliderVelocity, 4), effectsSignature); + } } } diff --git a/MapWizard.Tools/MapCleaner/MapCleanerAnalysis.cs b/MapWizard.Tools/MapCleaner/MapCleanerAnalysis.cs deleted file mode 100644 index d60150d..0000000 --- a/MapWizard.Tools/MapCleaner/MapCleanerAnalysis.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MapWizard.Tools.MapCleaner; - -public class MapCleanerAnalysis -{ - public bool UsesSliderVolumeChanges; - public bool UsesSliderSampleSetChanges; - public bool UsesSpinnerVolumeChanges; - public string BeatDivisorSignature = string.Empty; -} diff --git a/MapWizard.Tools/MapCleaner/MapCleanerOptions.cs b/MapWizard.Tools/MapCleaner/MapCleanerOptions.cs index ad254f6..040c24f 100644 --- a/MapWizard.Tools/MapCleaner/MapCleanerOptions.cs +++ b/MapWizard.Tools/MapCleaner/MapCleanerOptions.cs @@ -4,18 +4,10 @@ public class MapCleanerOptions { public List SnapDivisors = ["1/8", "1/12"]; - public bool AnalyzeSamples = true; - public bool ResnapObjects = true; - public bool ResnapSliderEnds = true; - public bool ResnapGreenLines = true; - public bool ResnapBookmarks; + public bool ResnapEverything = true; - public bool RemoveUnusedInheritedTimingPoints = true; - public bool RemoveHitSounds; - public bool RemoveUnusedSamples; public bool RemoveMuting; - public bool MuteUnclickableHitsounds; + public bool RemoveUnusedGreenlines; - public int RedlineLookaheadForObjectsMs = 10; - public int RedlineLookaheadForEndsMs = 20; + public int ForwardRedlineWindowMs = 10; } diff --git a/MapWizard.Tools/MapCleaner/MapCleanerResult.cs b/MapWizard.Tools/MapCleaner/MapCleanerResult.cs index 1d2fe67..f15f933 100644 --- a/MapWizard.Tools/MapCleaner/MapCleanerResult.cs +++ b/MapWizard.Tools/MapCleaner/MapCleanerResult.cs @@ -2,18 +2,16 @@ namespace MapWizard.Tools.MapCleaner; public class MapCleanerResult { + public int TimingPointsResnapped; public int ObjectsResnapped; public int SliderEndsResnapped; public int SpinnerOrHoldEndsResnapped; public int BookmarksResnapped; + public int PreviewTimeResnapped; public int GreenLinesResnapped; - public int InheritedTimingPointsRemoved; - public int HitSoundsRemoved; + public int GreenLinesRemoved; public int MutedTimingPointsRestored; - public int UnclickableHitSoundsMuted; - - public MapCleanerAnalysis Analysis = new(); } public class MapCleanerBatchResult @@ -23,27 +21,27 @@ public class MapCleanerBatchResult public List FailedPaths = []; public List FailureDetails = []; + public int TimingPointsResnapped; public int ObjectsResnapped; public int SliderEndsResnapped; public int SpinnerOrHoldEndsResnapped; public int BookmarksResnapped; + public int PreviewTimeResnapped; public int GreenLinesResnapped; - public int InheritedTimingPointsRemoved; - public int HitSoundsRemoved; + public int GreenLinesRemoved; public int MutedTimingPointsRestored; - public int UnclickableHitSoundsMuted; public void Add(MapCleanerResult result) { + TimingPointsResnapped += result.TimingPointsResnapped; ObjectsResnapped += result.ObjectsResnapped; SliderEndsResnapped += result.SliderEndsResnapped; SpinnerOrHoldEndsResnapped += result.SpinnerOrHoldEndsResnapped; BookmarksResnapped += result.BookmarksResnapped; + PreviewTimeResnapped += result.PreviewTimeResnapped; GreenLinesResnapped += result.GreenLinesResnapped; - InheritedTimingPointsRemoved += result.InheritedTimingPointsRemoved; - HitSoundsRemoved += result.HitSoundsRemoved; + GreenLinesRemoved += result.GreenLinesRemoved; MutedTimingPointsRestored += result.MutedTimingPointsRestored; - UnclickableHitSoundsMuted += result.UnclickableHitSoundsMuted; } } diff --git a/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs b/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs index 9a50acc..5898086 100644 --- a/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs +++ b/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs @@ -77,7 +77,16 @@ public static int SnapMilliseconds( var redlineTime = snapTimingPoint.Time.TotalMilliseconds; var beatLength = snapTimingPoint.BeatLength; - if (Math.Abs(beatLength) < 0.00001) + return SnapRelativeMilliseconds(objectTimeMs, redlineTime, beatLength, divisors); + } + + public static int SnapRelativeMilliseconds( + double objectTimeMs, + double anchorTimeMs, + double beatLength, + IReadOnlyList divisors) + { + if (divisors.Count == 0 || Math.Abs(beatLength) < 0.00001) { return StableRound(objectTimeMs); } @@ -93,12 +102,12 @@ public static int SnapMilliseconds( continue; } - var relativeStep = (objectTimeMs - redlineTime) / step; + var relativeStep = (objectTimeMs - anchorTimeMs) / step; var nearestStep = (int)Math.Round(relativeStep, MidpointRounding.AwayFromZero); for (var offset = -1; offset <= 1; offset++) { - var candidateTime = redlineTime + ((nearestStep + offset) * step); + var candidateTime = anchorTimeMs + ((nearestStep + offset) * step); var candidateRounded = StableRound(candidateTime); var candidateDistance = Math.Abs(candidateRounded - objectTimeMs); @@ -109,13 +118,9 @@ public static int SnapMilliseconds( continue; } - if (Math.Abs(candidateDistance - bestDistance) < 0.00001) + if (Math.Abs(candidateDistance - bestDistance) < 0.00001 && candidateRounded < best) { - // Favor earlier snaps to reduce accidental forward drift. - if (candidateRounded < best) - { - best = candidateRounded; - } + best = candidateRounded; } } } diff --git a/MapWizard.Tools/MapCleaner/Timing/InheritedPruner.cs b/MapWizard.Tools/MapCleaner/Timing/InheritedPruner.cs deleted file mode 100644 index 770bd4e..0000000 --- a/MapWizard.Tools/MapCleaner/Timing/InheritedPruner.cs +++ /dev/null @@ -1,120 +0,0 @@ -using BeatmapParser; -using BeatmapParser.TimingPoints; - -namespace MapWizard.Tools.MapCleaner.Timing; - -public static class InheritedPruner -{ - public static int PruneUnusedInheritedTimingPoints(Beatmap beatmap, MapCleanerAnalysis analysis) - { - var section = beatmap.TimingPoints; - if (section == null || section.TimingPointList.Count == 0) - { - return 0; - } - - var originalTimingPoints = section.TimingPointList.ToList(); - var requiredTimes = TimingInfluenceRebuilder.BuildRequiredTimes( - beatmap, - includeSliderBodyTiming: analysis.UsesSliderVolumeChanges || analysis.UsesSliderSampleSetChanges, - includeSpinnerBodyTiming: analysis.UsesSpinnerVolumeChanges); - - if (requiredTimes.Count == 0) - { - return 0; - } - - var ordered = originalTimingPoints - .Select((tp, idx) => new IndexedTimingPoint(idx, tp)) - .OrderBy(x => x.TimingPoint.Time.TotalMilliseconds) - .ThenBy(x => x.Index) - .ToList(); - - var activeInheritedIndices = new HashSet(); - - foreach (var time in requiredTimes) - { - var active = FindActiveTimingPoint(ordered, time); - if (active?.TimingPoint is InheritedTimingPoint) - { - activeInheritedIndices.Add(active.Index); - } - } - - var filtered = originalTimingPoints - .Select((timingPoint, index) => new IndexedTimingPoint(index, timingPoint)) - .Where(x => x.TimingPoint is not InheritedTimingPoint || activeInheritedIndices.Contains(x.Index)) - .OrderBy(x => x.TimingPoint.Time.TotalMilliseconds) - .ThenBy(x => x.Index) - .ToList(); - - var cleaned = new List(filtered.Count); - - foreach (var item in filtered) - { - if (cleaned.Count > 0 && cleaned[^1] is InheritedTimingPoint previousInherited && - item.TimingPoint is InheritedTimingPoint currentInherited && - AreEquivalentInherited(previousInherited, currentInherited)) - { - continue; - } - - cleaned.Add(item.TimingPoint); - } - - var removed = originalTimingPoints.Count - cleaned.Count; - section.TimingPointList = cleaned; - beatmap.TimingPoints = section; - - return removed; - } - - private static IndexedTimingPoint? FindActiveTimingPoint(IReadOnlyList orderedTimingPoints, double time) - { - IndexedTimingPoint? active = null; - - foreach (var timingPoint in orderedTimingPoints) - { - if (timingPoint.TimingPoint.Time.TotalMilliseconds > time) - { - break; - } - - active = timingPoint; - } - - return active; - } - - private static bool AreEquivalentInherited(InheritedTimingPoint previous, InheritedTimingPoint current) - { - if (previous.SampleSet != current.SampleSet || - previous.SampleIndex != current.SampleIndex || - previous.Volume != current.Volume) - { - return false; - } - - if (Math.Abs(previous.SliderVelocity - current.SliderVelocity) > 0.0005) - { - return false; - } - - if (previous.Effects.Count != current.Effects.Count) - { - return false; - } - - for (var i = 0; i < previous.Effects.Count; i++) - { - if (previous.Effects[i] != current.Effects[i]) - { - return false; - } - } - - return true; - } - - private sealed record IndexedTimingPoint(int Index, TimingPoint TimingPoint); -} diff --git a/MapWizard.Tools/MapCleaner/Timing/TimingInfluenceRebuilder.cs b/MapWizard.Tools/MapCleaner/Timing/TimingInfluenceRebuilder.cs deleted file mode 100644 index 91942fe..0000000 --- a/MapWizard.Tools/MapCleaner/Timing/TimingInfluenceRebuilder.cs +++ /dev/null @@ -1,97 +0,0 @@ -using BeatmapParser; -using BeatmapParser.HitObjects; - -namespace MapWizard.Tools.MapCleaner.Timing; - -internal static class TimingInfluenceRebuilder -{ - public static IReadOnlyList BuildRequiredTimes( - Beatmap beatmap, - bool includeSliderBodyTiming, - bool includeSpinnerBodyTiming) - { - var requiredTimes = new HashSet(); - - foreach (var hitObject in beatmap.HitObjects.Objects) - { - requiredTimes.Add(hitObject.Time.TotalMilliseconds); - - switch (hitObject) - { - case Slider slider: - requiredTimes.Add(slider.EndTime.TotalMilliseconds); - break; - case Spinner spinner: - requiredTimes.Add(spinner.End.TotalMilliseconds); - break; - case ManiaHold maniaHold: - requiredTimes.Add(maniaHold.End.TotalMilliseconds); - break; - } - } - - if (beatmap.Editor?.Bookmarks != null) - { - foreach (var bookmark in beatmap.Editor.Bookmarks) - { - requiredTimes.Add(bookmark.TotalMilliseconds); - } - } - - if (beatmap.TimingPoints != null) - { - if (includeSliderBodyTiming) - { - foreach (var slider in beatmap.HitObjects.Objects.OfType()) - { - var start = slider.Time.TotalMilliseconds; - var end = slider.EndTime.TotalMilliseconds; - - foreach (var timingPoint in beatmap.TimingPoints.TimingPointList) - { - var tpTime = timingPoint.Time.TotalMilliseconds; - if (tpTime > start && tpTime < end) - { - requiredTimes.Add(tpTime); - } - } - } - } - - if (includeSpinnerBodyTiming) - { - foreach (var spinner in beatmap.HitObjects.Objects.OfType()) - { - var start = spinner.Time.TotalMilliseconds; - var end = spinner.End.TotalMilliseconds; - - foreach (var timingPoint in beatmap.TimingPoints.TimingPointList) - { - var tpTime = timingPoint.Time.TotalMilliseconds; - if (tpTime > start && tpTime < end) - { - requiredTimes.Add(tpTime); - } - } - } - - foreach (var hold in beatmap.HitObjects.Objects.OfType()) - { - var start = hold.Time.TotalMilliseconds; - var end = hold.End.TotalMilliseconds; - - foreach (var timingPoint in beatmap.TimingPoints.TimingPointList) - { - var tpTime = timingPoint.Time.TotalMilliseconds; - if (tpTime > start && tpTime < end) - { - requiredTimes.Add(tpTime); - } - } - } - } - } - - return requiredTimes.OrderBy(x => x).ToList(); - } -} diff --git a/MapWizard.Tools/MapWizard.Tools.csproj b/MapWizard.Tools/MapWizard.Tools.csproj index a73224e..e380e2a 100644 --- a/MapWizard.Tools/MapWizard.Tools.csproj +++ b/MapWizard.Tools/MapWizard.Tools.csproj @@ -8,7 +8,7 @@ - +