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 @@
-
+