From ada02f305e0905a1d021fcb235635afd78d3feca Mon Sep 17 00:00:00 2001 From: maotovisk Date: Fri, 27 Mar 2026 18:28:56 -0300 Subject: [PATCH 1/2] fix: better math regarding slider ends snapping --- MapWizard.Tests/MapCleaner/MapCleanerTests.cs | 323 +++++++++++++++++- MapWizard.Tools/MapCleaner/MapCleaner.cs | 97 +++++- 2 files changed, 402 insertions(+), 18 deletions(-) diff --git a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs index 5e149bb..4da0b06 100644 --- a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs +++ b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs @@ -51,7 +51,7 @@ public void CleanBeatmap_ResnapEverything_ResnapsTimingPointsObjectsBookmarksAnd Assert.Equal(TimeSpan.FromMilliseconds(125), circle.Time); Assert.Equal(TimeSpan.FromMilliseconds(125), greenline.Time); - Assert.Equal(TimeSpan.FromMilliseconds(625), secondRedline.Time); + Assert.Equal(TimeSpan.FromMilliseconds(626), secondRedline.Time); Assert.NotNull(beatmap.Editor); var editor = beatmap.Editor!; Assert.NotNull(editor.Bookmarks); @@ -144,11 +144,105 @@ public void CleanBeatmap_ResnapEverything_ResnapsRedlinesRelativeToPreviousRefer .ToList(); Assert.Equal(TimeSpan.FromMilliseconds(0), redlines[0].Time); - Assert.Equal(TimeSpan.FromMilliseconds(375), redlines[1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(378), redlines[1].Time); Assert.Equal(TimeSpan.FromMilliseconds(878), redlines[2].Time); Assert.Equal(2, result.TimingPointsResnapped); } + [Fact] + public void CleanBeatmap_ResnapEverything_DoesNotMoveRedlineAnchoredToObjectStart() + { + var beatmap = Beatmap.Decode(GetAnchoredRedlineBeatmap()); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + var redlines = beatmap.TimingPoints!.TimingPointList + .OfType() + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + var slider = beatmap.HitObjects.Objects.OfType().Single(); + + Assert.Equal(TimeSpan.FromMilliseconds(3012), redlines[1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(3012), slider.Time); + Assert.Equal(0, result.TimingPointsResnapped); + Assert.Equal(0, result.ObjectsResnapped); + } + + [Fact] + public void CleanBeatmap_ResnapEverything_DoesNotMoveGreenlineAnchoredToSliderStart() + { + var beatmap = Beatmap.Decode(GetAnchoredGreenlineBeatmap()); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + var greenline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + var redline = beatmap.TimingPoints.TimingPointList.OfType().Last(); + var slider = beatmap.HitObjects.Objects.OfType().Single(); + + Assert.Equal(TimeSpan.FromMilliseconds(3012), greenline.Time); + Assert.Equal(TimeSpan.FromMilliseconds(3012), redline.Time); + Assert.Equal(TimeSpan.FromMilliseconds(3012), slider.Time); + Assert.Equal(0, result.TimingPointsResnapped); + Assert.Equal(0, result.GreenLinesResnapped); + Assert.Equal(0, result.SliderEndsResnapped); + } + + [Fact] + public void CleanBeatmap_ResnapEverything_ResnapsOffSnapSliderEndEvenWhenStartDoesNotMove() + { + var beatmap = Beatmap.Decode(GetAnchoredGreenlineOffSnapSliderEndBeatmap()); + var slider = 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.Equal(TimeSpan.FromMilliseconds(3012), slider.Time); + Assert.Equal(TimeSpan.FromMilliseconds(3345), slider.EndTime); + Assert.Equal(1, result.SliderEndsResnapped); + } + + [Fact] + public void CleanBeatmap_ResnapEverything_ResnapsSliderEndAgainstCleanedRedlinePhase() + { + var beatmap = Beatmap.Decode(GetSliderTailOffSnapAfterRedlineResnapBeatmap()); + var slider = beatmap.HitObjects.Objects.OfType().Single(); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + var redline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + + Assert.Equal(TimeSpan.FromMilliseconds(0), redline.Time); + Assert.Equal(TimeSpan.FromMilliseconds(0), slider.Time); + Assert.Equal(TimeSpan.FromMilliseconds(83), slider.EndTime); + Assert.InRange(slider.Length, 34.859, 34.861); + Assert.Equal(1, result.TimingPointsResnapped); + Assert.Equal(0, result.ObjectsResnapped); + Assert.Equal(1, result.SliderEndsResnapped); + } + [Fact] public void CleanBeatmap_RemoveMuting_RestoresMutedTimingPoints() { @@ -350,8 +444,8 @@ osu file format v14 [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 + 101.4,-100,4,1,0,100,0,0 + 626.4,500,4,1,0,100,1,0 [HitObjects] 256,192,126,1,0,0:0:0:0: @@ -564,8 +658,8 @@ osu file format v14 [TimingPoints] 0,500,4,1,0,100,1,0 - 378,400,4,1,0,100,1,0 - 876,400,4,1,0,100,1,0 + 378.4,400,4,1,0,100,1,0 + 876.4,400,4,1,0,100,1,0 [HitObjects] 256,192,0,1,0,0:0:0:0: @@ -628,6 +722,223 @@ osu file format v14 """.Replace("\n", "\r\n"); } + private static string GetAnchoredRedlineBeatmap() + { + 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] + 2354,329.670329670329,4,2,3,40,1,0 + 3012,333.333333333333,4,2,3,40,1,0 + + [HitObjects] + 256,192,3012,2,0,B|356:192,1,140 + """.Replace("\n", "\r\n"); + } + + private static string GetAnchoredGreenlineBeatmap() + { + 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] + 2354,329.670329670329,4,2,3,40,1,0 + 3012,333.333333333333,4,2,3,40,1,0 + 3012,-100,4,2,3,40,0,0 + + [HitObjects] + 256,192,3012,2,0,B|356:192,1,140 + """.Replace("\n", "\r\n"); + } + + private static string GetAnchoredGreenlineOffSnapSliderEndBeatmap() + { + 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] + 2354,329.670329670329,4,2,3,40,1,0 + 3012,333.333333333333,4,2,3,40,1,0 + 3012,-100,4,2,3,40,0,0 + + [HitObjects] + 256,192,3012,2,0,B|356:192,1,145 + """.Replace("\n", "\r\n"); + } + + private static string GetSliderTailOffSnapAfterRedlineResnapBeatmap() + { + 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.4,333.333333333333,4,1,0,100,1,0 + + [HitObjects] + 256,192,0,2,0,B|356:192,1,35.28 + """.Replace("\n", "\r\n"); + } + private static string GetGreenlinePruningBeatmap() { return """ diff --git a/MapWizard.Tools/MapCleaner/MapCleaner.cs b/MapWizard.Tools/MapCleaner/MapCleaner.cs index 7a2a473..16dafdd 100644 --- a/MapWizard.Tools/MapCleaner/MapCleaner.cs +++ b/MapWizard.Tools/MapCleaner/MapCleaner.cs @@ -101,6 +101,14 @@ private static int ResnapTimingPoints( foreach (var timingPoint in beatmap.TimingPoints.TimingPointList) { var originalTime = timingPoint.Time.TotalMilliseconds; + + // Exact millisecond timing points are already in stable's authored representation. + // Re-snapping them to a different whole millisecond is too destructive for section starts and SV anchors. + if (IsExactMillisecond(originalTime)) + { + continue; + } + int snappedTime; if (timingPoint is UninheritedTimingPoint) @@ -154,6 +162,21 @@ private static int ResnapTimingPoints( return timingPointsResnapped; } + private static bool IsExactMillisecond(double timeMs) + { + return Math.Abs(timeMs - StableSnapEngine.StableRound(timeMs)) <= 0.0001; + } + + private static bool IsOffSnap( + double timeMs, + BeatmapParser.Sections.TimingPointsSection? timingPoints, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + var snappedTime = StableSnapEngine.SnapMilliseconds(timeMs, timingPoints, divisors, forwardRedlineWindowMs); + return Math.Abs(snappedTime - timeMs) > 0.0001; + } + private static int ResnapHitObjectStartTimes( Beatmap beatmap, Beatmap referenceBeatmap, @@ -206,22 +229,44 @@ private static void ResnapHitObjectEndsAndSliderLengths( { var hitObject = beatmap.HitObjects.Objects[index]; var referenceObject = index < referenceObjects.Count ? referenceObjects[index] : null; + if (referenceObject == null) + { + continue; + } + + var startChanged = Math.Abs(hitObject.Time.TotalMilliseconds - referenceObject.Time.TotalMilliseconds) > 0.0001; switch (hitObject) { case Slider slider when referenceObject is Slider referenceSlider: - if (ResnapSliderEndAndLength(beatmap, referenceBeatmap, slider, referenceSlider, divisors, options)) + var currentStableSliderEnd = CalculateStableSliderEndTimeMilliseconds(beatmap, slider); + if (!startChanged && !IsOffSnap(currentStableSliderEnd, beatmap.TimingPoints, divisors, options.ForwardRedlineWindowMs)) + { + continue; + } + + if (ResnapSliderEndAndLength(beatmap, slider, divisors, options.ForwardRedlineWindowMs)) { result.SliderEndsResnapped++; } break; case Spinner spinner when referenceObject is Spinner referenceSpinner: + if (!startChanged && !IsOffSnap(spinner.End.TotalMilliseconds, referenceBeatmap.TimingPoints, divisors, options.ForwardRedlineWindowMs)) + { + continue; + } + if (ResnapSpinnerEnd(referenceBeatmap, spinner, referenceSpinner, divisors, options.ForwardRedlineWindowMs)) { result.SpinnerOrHoldEndsResnapped++; } break; case ManiaHold maniaHold when referenceObject is ManiaHold referenceHold: + if (!startChanged && !IsOffSnap(maniaHold.End.TotalMilliseconds, referenceBeatmap.TimingPoints, divisors, options.ForwardRedlineWindowMs)) + { + continue; + } + if (ResnapHoldEnd(referenceBeatmap, maniaHold, referenceHold, divisors, options.ForwardRedlineWindowMs)) { result.SpinnerOrHoldEndsResnapped++; @@ -233,31 +278,26 @@ private static void ResnapHitObjectEndsAndSliderLengths( private static bool ResnapSliderEndAndLength( Beatmap beatmap, - Beatmap referenceBeatmap, Slider slider, - Slider referenceSlider, IReadOnlyList divisors, - MapCleanerOptions options) + int forwardRedlineWindowMs) { - 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 currentStableEnd = CalculateStableSliderEndTimeMilliseconds(beatmap, slider); - var targetEnd = snappedStart + originalDuration; var snappedEnd = StableSnapEngine.SnapMilliseconds( - targetEnd, - referenceBeatmap.TimingPoints, + currentStableEnd, + beatmap.TimingPoints, divisors, - options.ForwardRedlineWindowMs); + forwardRedlineWindowMs); if (snappedEnd <= snappedStart) { snappedEnd = StableSnapEngine.StableRound(snappedStart + 1); } - var endChanged = Math.Abs(snappedEnd - slider.EndTime.TotalMilliseconds) > 0.0001; + var endChanged = Math.Abs(snappedEnd - currentStableEnd) > 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; @@ -268,6 +308,39 @@ private static bool ResnapSliderEndAndLength( return endChanged || lengthChanged; } + private static double CalculateStableSliderEndTimeMilliseconds(Beatmap beatmap, Slider slider) + { + return slider.Time.TotalMilliseconds + CalculateStableSliderDurationMilliseconds(beatmap, slider); + } + + private static double CalculateStableSliderDurationMilliseconds(Beatmap beatmap, Slider slider) + { + var startMs = slider.Time.TotalMilliseconds; + var uninheritedTimingPoint = beatmap.GetUninheritedTimingPointAt(startMs); + var beatLength = Math.Abs(uninheritedTimingPoint?.BeatLength ?? 0); + if (beatLength <= 0.00001) + { + beatLength = 1.0; + } + + var inheritedTimingPoint = beatmap.GetInheritedTimingPointAt(startMs); + var sliderVelocity = Math.Abs(inheritedTimingPoint?.SliderVelocity ?? 1.0); + if (sliderVelocity <= 0.00001) + { + sliderVelocity = 1.0; + } + + var sliderMultiplier = Math.Abs(beatmap.DifficultySection.SliderMultiplier); + if (sliderMultiplier <= 0.00001) + { + sliderMultiplier = 1.0; + } + + var slides = Math.Max(1, (int)slider.Slides); + var duration = (slider.Length * beatLength * slides) / (sliderMultiplier * 100.0 * sliderVelocity); + return Math.Max(1.0, StableSnapEngine.StableRound(duration)); + } + private static bool ResnapSpinnerEnd( Beatmap referenceBeatmap, Spinner spinner, From 8e690044027bc4b1debf860308fb4fee4c608b46 Mon Sep 17 00:00:00 2001 From: maotovisk Date: Fri, 27 Mar 2026 23:26:51 -0300 Subject: [PATCH 2/2] feat: improve reverse with multiple timing points span --- MapWizard.Tests/MapCleaner/MapCleanerTests.cs | 169 +++++++++ MapWizard.Tools/MapCleaner/MapCleaner.cs | 336 +++++++++++++++++- 2 files changed, 504 insertions(+), 1 deletion(-) diff --git a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs index 4da0b06..1b48a8f 100644 --- a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs +++ b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs @@ -3,6 +3,7 @@ using BeatmapParser.TimingPoints; using MapWizard.Tools.HitSounds.Copier; using MapWizard.Tools.MapCleaner; +using MapWizard.Tools.MapCleaner.Snapping; namespace MapWizard.Tests.MapCleaner; @@ -243,6 +244,63 @@ public void CleanBeatmap_ResnapEverything_ResnapsSliderEndAgainstCleanedRedlineP Assert.Equal(1, result.SliderEndsResnapped); } + [Fact] + public void CleanBeatmap_ResnapEverything_ReverseSliderWithMultipleAffectedSpans_PrioritizesAllEdgesUnderTwoMilliseconds() + { + var beatmap = Beatmap.Decode(GetReverseSliderMultipleSpanRedlineBeatmap()); + var slider = beatmap.HitObjects.Objects.OfType().Single(); + var divisors = StableSnapEngine.ParseDivisors(["1/4"]); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + Assert.Equal(TimeSpan.FromMilliseconds(0), slider.Time); + Assert.Equal(TimeSpan.FromMilliseconds(360), slider.EndTime); + Assert.InRange(slider.Length, 33.599, 33.601); + Assert.Equal(0, result.ObjectsResnapped); + Assert.Equal(1, result.SliderEndsResnapped); + + var duration = slider.EndTime.TotalMilliseconds - slider.Time.TotalMilliseconds; + for (var edgeIndex = 1; edgeIndex <= (int)slider.Slides; edgeIndex++) + { + var edgeTime = slider.Time.TotalMilliseconds + (duration * edgeIndex / slider.Slides); + var snappedEdge = StableSnapEngine.SnapMilliseconds(edgeTime, beatmap.TimingPoints, divisors, 10); + Assert.True(Math.Abs(snappedEdge - edgeTime) < 2.0, $"Edge {edgeIndex} stayed {Math.Abs(snappedEdge - edgeTime):F3}ms off snap."); + } + } + + [Fact] + public void CleanBeatmap_ResnapEverything_ReverseSliderWithSnappedTailButOffSnapRepeat_KeepsEstimatedSpanDuration() + { + var beatmap = Beatmap.Decode(GetReverseSliderFromRecallTheEndBeatmap()); + var slider = beatmap.HitObjects.Objects.OfType().Single(); + var divisors = StableSnapEngine.ParseDivisors(["1/8", "1/12"]); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false + }); + + Assert.Equal(TimeSpan.FromMilliseconds(218717), slider.Time); + Assert.Equal(TimeSpan.FromMilliseconds(218955), slider.EndTime); + Assert.InRange(slider.Length, 40.393, 40.394); + Assert.Equal(0, result.ObjectsResnapped); + Assert.Equal(0, result.SliderEndsResnapped); + + var duration = slider.EndTime.TotalMilliseconds - slider.Time.TotalMilliseconds; + var secondRepeatTime = slider.Time.TotalMilliseconds + (duration * 2 / slider.Slides); + var snappedSecondRepeat = StableSnapEngine.SnapMilliseconds(secondRepeatTime, beatmap.TimingPoints, divisors, 10); + + Assert.Equal(2.6666666666569654, Math.Abs(snappedSecondRepeat - secondRepeatTime), precision: 6); + } + [Fact] public void CleanBeatmap_RemoveMuting_RestoresMutedTimingPoints() { @@ -939,6 +997,117 @@ osu file format v14 """.Replace("\n", "\r\n"); } + private static string GetReverseSliderMultipleSpanRedlineBeatmap() + { + 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 + 120,480,4,1,0,100,1,0 + 360,360,4,1,0,100,1,0 + + [HitObjects] + 256,192,0,2,0,B|356:192,3,38.2666666666667 + """.Replace("\n", "\r\n"); + } + + private static string GetReverseSliderFromRecallTheEndBeatmap() + { + return """ + osu file format v14 + + [General] + AudioFilename: a.mp3 + AudioLeadIn: 0 + PreviewTime: -1 + Countdown: 0 + SampleSet: Soft + StackLeniency: 0.7 + Mode: 0 + LetterboxInBreaks: 0 + WidescreenStoryboard: 1 + + [Editor] + DistanceSpacing: 1.3 + BeatDivisor: 8 + GridSize: 4 + TimelineZoom: 5.839996 + + [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.38 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 218083,-81.3008130081301,4,2,3,80,0,0 + 218248,312.5,4,2,3,80,1,0 + 218717,-86.7302688638335,4,2,3,80,0,0 + 218873,329.67032967033,4,2,3,80,1,0 + + [HitObjects] + 489,137,218717,2,0,L|441:127,3,40.393740799999996,0|0|0|0,1:0|1:0|1:0|1:0,0:0:0:0: + """.Replace("\n", "\r\n"); + } + private static string GetGreenlinePruningBeatmap() { return """ diff --git a/MapWizard.Tools/MapCleaner/MapCleaner.cs b/MapWizard.Tools/MapCleaner/MapCleaner.cs index 16dafdd..fd6d7e1 100644 --- a/MapWizard.Tools/MapCleaner/MapCleaner.cs +++ b/MapWizard.Tools/MapCleaner/MapCleaner.cs @@ -240,7 +240,12 @@ private static void ResnapHitObjectEndsAndSliderLengths( { case Slider slider when referenceObject is Slider referenceSlider: var currentStableSliderEnd = CalculateStableSliderEndTimeMilliseconds(beatmap, slider); - if (!startChanged && !IsOffSnap(currentStableSliderEnd, beatmap.TimingPoints, divisors, options.ForwardRedlineWindowMs)) + var reverseAffectedByUninheritedTimingPoint = + ReverseAffectedByUninheritedTimingPoint(beatmap, slider, currentStableSliderEnd); + + if (!startChanged && + !reverseAffectedByUninheritedTimingPoint && + !IsOffSnap(currentStableSliderEnd, beatmap.TimingPoints, divisors, options.ForwardRedlineWindowMs)) { continue; } @@ -285,6 +290,29 @@ private static bool ResnapSliderEndAndLength( var slides = Math.Max(1, (int)slider.Slides); var snappedStart = slider.Time.TotalMilliseconds; var currentStableEnd = CalculateStableSliderEndTimeMilliseconds(beatmap, slider); + var currentDuration = Math.Max(1.0, currentStableEnd - snappedStart); + + if (ReverseAffectedByUninheritedTimingPoint(beatmap, slider, currentStableEnd) && + TryResnapReverseSliderEdgesAndLength( + beatmap, + slider, + divisors, + forwardRedlineWindowMs, + slides, + snappedStart, + currentDuration, + out var reverseDuration)) + { + var reverseEnd = snappedStart + reverseDuration; + var reverseEndChanged = Math.Abs(reverseEnd - currentStableEnd) > 0.0001; + var reverseLength = CalculateSliderLengthFromDuration(beatmap, slider, reverseDuration, slides); + var reverseLengthChanged = Math.Abs(reverseLength - slider.Length) > 0.0001; + + slider.EndTime = TimeSpan.FromMilliseconds(reverseEnd); + slider.Length = reverseLength; + + return reverseEndChanged || reverseLengthChanged; + } var snappedEnd = StableSnapEngine.SnapMilliseconds( currentStableEnd, @@ -308,6 +336,268 @@ private static bool ResnapSliderEndAndLength( return endChanged || lengthChanged; } + private static bool ReverseAffectedByUninheritedTimingPoint(Beatmap beatmap, Slider slider, double endMs) + { + if (beatmap.TimingPoints == null) + { + return false; + } + + var slides = Math.Max(1, (int)slider.Slides); + if (slides <= 1) + { + return false; + } + + var startMs = slider.Time.TotalMilliseconds; + if (endMs <= startMs + 0.0001) + { + return false; + } + + var uninheritedWithinSlider = beatmap.TimingPoints.TimingPointList + .OfType() + .Where(x => x.Time.TotalMilliseconds > startMs && x.Time.TotalMilliseconds < endMs) + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + + if (uninheritedWithinSlider.Count == 0) + { + return false; + } + + var duration = endMs - startMs; + var affectedEdgeCount = 0; + + for (var edgeIndex = 1; edgeIndex <= slides; edgeIndex++) + { + var edgeTime = startMs + (duration * edgeIndex / slides); + + if (!uninheritedWithinSlider.Any(x => x.Time.TotalMilliseconds <= edgeTime + 0.0001)) + { + continue; + } + + affectedEdgeCount++; + if (affectedEdgeCount > 1) + { + return true; + } + } + + return false; + } + + private static bool TryResnapReverseSliderEdgesAndLength( + Beatmap beatmap, + Slider slider, + IReadOnlyList divisors, + int forwardRedlineWindowMs, + int slides, + double startMs, + double currentDuration, + out int bestDuration) + { + bestDuration = StableSnapEngine.StableRound(currentDuration); + + if (beatmap.TimingPoints == null) + { + return false; + } + + var currentEnd = startMs + currentDuration; + var currentSpanDuration = currentDuration / slides; + var relevantRedlines = GetRelevantSliderRedlines(beatmap, startMs, currentEnd, forwardRedlineWindowMs); + if (relevantRedlines.Count == 0) + { + return false; + } + + var candidateDurations = new HashSet + { + Math.Max(1, StableSnapEngine.StableRound(currentDuration)) + }; + + var normalSnappedEnd = StableSnapEngine.SnapMilliseconds( + currentEnd, + beatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + + candidateDurations.Add(Math.Max(1, normalSnappedEnd - StableSnapEngine.StableRound(startMs))); + + foreach (var estimatedSpanDuration in EstimateSpanDurations(currentSpanDuration, relevantRedlines, divisors)) + { + var estimatedEnd = startMs + (estimatedSpanDuration * slides); + var snappedEnd = StableSnapEngine.SnapMilliseconds( + estimatedEnd, + beatmap.TimingPoints, + divisors, + forwardRedlineWindowMs); + + if (snappedEnd <= startMs) + { + continue; + } + + candidateDurations.Add(snappedEnd - StableSnapEngine.StableRound(startMs)); + } + + SliderEdgeScore? bestScore = null; + + foreach (var candidateDuration in candidateDurations) + { + if (candidateDuration <= 0) + { + continue; + } + + var score = EvaluateSliderDurationCandidate( + candidateDuration, + startMs, + slides, + beatmap.TimingPoints, + divisors, + forwardRedlineWindowMs, + currentDuration); + + if (bestScore == null || score.CompareTo(bestScore.Value) < 0) + { + bestScore = score; + bestDuration = candidateDuration; + } + } + + return bestScore != null; + } + + private static IEnumerable EstimateSpanDurations( + double currentSpanDuration, + IReadOnlyList redlines, + IReadOnlyList divisors) + { + var estimatedDurations = new HashSet(); + + foreach (var redline in redlines) + { + var beatLength = Math.Abs(redline.BeatLength); + if (beatLength <= 0.00001) + { + continue; + } + + foreach (var divisor in divisors) + { + var step = beatLength * divisor.Numerator / divisor.Denominator; + if (step <= 0.00001) + { + continue; + } + + var nearestStepCount = Math.Max(1, (int)Math.Round(currentSpanDuration / step, MidpointRounding.AwayFromZero)); + var candidateDuration = nearestStepCount * step; + estimatedDurations.Add(StableSnapEngine.StableRound(candidateDuration)); + } + } + + estimatedDurations.Add(StableSnapEngine.StableRound(currentSpanDuration)); + + return estimatedDurations + .OrderBy(x => Math.Abs(x - currentSpanDuration)) + .Select(x => (double)x) + .ToList(); + } + + private static List GetRelevantSliderRedlines( + Beatmap beatmap, + double startMs, + double endMs, + int forwardRedlineWindowMs) + { + if (beatmap.TimingPoints == null) + { + return []; + } + + var redlines = beatmap.TimingPoints.TimingPointList + .OfType() + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + + if (redlines.Count == 0) + { + return []; + } + + var relevant = new List(); + var activeAtStart = redlines.LastOrDefault(x => x.Time.TotalMilliseconds <= startMs) ?? redlines[0]; + relevant.Add(activeAtStart); + + foreach (var redline in redlines) + { + var redlineMs = redline.Time.TotalMilliseconds; + if (redlineMs <= startMs) + { + continue; + } + + if (redlineMs > endMs + forwardRedlineWindowMs) + { + break; + } + + relevant.Add(redline); + } + + return relevant + .Distinct() + .OrderBy(x => x.Time.TotalMilliseconds) + .ToList(); + } + + private static SliderEdgeScore EvaluateSliderDurationCandidate( + int durationMs, + double startMs, + int slides, + BeatmapParser.Sections.TimingPointsSection timingPoints, + IReadOnlyList divisors, + int forwardRedlineWindowMs, + double currentDuration) + { + var maxUnsnap = 0.0; + var totalUnsnap = 0.0; + var endUnsnap = 0.0; + var allEdgesUnderTwoMs = true; + + for (var edgeIndex = 1; edgeIndex <= slides; edgeIndex++) + { + var edgeTime = startMs + (durationMs * edgeIndex / (double)slides); + var snappedEdge = StableSnapEngine.SnapMilliseconds(edgeTime, timingPoints, divisors, forwardRedlineWindowMs); + var unsnap = Math.Abs(snappedEdge - edgeTime); + + maxUnsnap = Math.Max(maxUnsnap, unsnap); + totalUnsnap += unsnap; + + if (edgeIndex == slides) + { + endUnsnap = unsnap; + } + + if (unsnap >= 2.0) + { + allEdgesUnderTwoMs = false; + } + } + + return new SliderEdgeScore( + allEdgesUnderTwoMs, + maxUnsnap, + totalUnsnap, + endUnsnap, + Math.Abs(durationMs - currentDuration), + durationMs); + } + private static double CalculateStableSliderEndTimeMilliseconds(Beatmap beatmap, Slider slider) { return slider.Time.TotalMilliseconds + CalculateStableSliderDurationMilliseconds(beatmap, slider); @@ -708,6 +998,50 @@ private static void SortTimingPoints(Beatmap beatmap) private sealed record IndexedTimingPoint(int Index, TimingPoint TimingPoint); + private readonly record struct SliderEdgeScore( + bool AllEdgesUnderTwoMs, + double MaxUnsnap, + double TotalUnsnap, + double EndUnsnap, + double DurationDelta, + int DurationMs) : IComparable + { + public int CompareTo(SliderEdgeScore other) + { + var underTwoComparison = other.AllEdgesUnderTwoMs.CompareTo(AllEdgesUnderTwoMs); + if (underTwoComparison != 0) + { + return underTwoComparison; + } + + var maxComparison = MaxUnsnap.CompareTo(other.MaxUnsnap); + if (maxComparison != 0) + { + return maxComparison; + } + + var totalComparison = TotalUnsnap.CompareTo(other.TotalUnsnap); + if (totalComparison != 0) + { + return totalComparison; + } + + var endComparison = EndUnsnap.CompareTo(other.EndUnsnap); + if (endComparison != 0) + { + return endComparison; + } + + var deltaComparison = DurationDelta.CompareTo(other.DurationDelta); + if (deltaComparison != 0) + { + return deltaComparison; + } + + return DurationMs.CompareTo(other.DurationMs); + } + } + private sealed record InheritedState( BeatmapParser.Enums.SampleSet sampleSet, uint sampleIndex,