diff --git a/MapWizard.CLI/Program.cs b/MapWizard.CLI/Program.cs index 9fb9d16..2c728a8 100644 --- a/MapWizard.CLI/Program.cs +++ b/MapWizard.CLI/Program.cs @@ -100,6 +100,7 @@ private static void RunMapCleaner(string[] targetPaths, MapCleanerOptions option Console.WriteLine( $"Map cleaner finished. Cleaned {result.ProcessedBeatmaps} beatmap(s): " + $"{result.TimingPointsResnapped} timing points resnapped, " + + $"{result.GreenLinesResnapped} greenlines resnapped, " + $"{result.ObjectsResnapped} object starts resnapped, " + $"{result.SliderEndsResnapped} slider ends resnapped, " + $"{result.GreenLinesRemoved} greenlines removed."); diff --git a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs index 947391c..5f7277f 100644 --- a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs @@ -230,7 +230,7 @@ private void CleanMaps() toastManager.ShowToast( NotificationType.Success, "Map Cleaner", - $"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."); + $"Done. Resnapped {result.TimingPointsResnapped} timing points, {result.GreenLinesResnapped} greenlines, {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.Tests/MapCleaner/MapCleanerTests.cs b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs index 1b48a8f..418b767 100644 --- a/MapWizard.Tests/MapCleaner/MapCleanerTests.cs +++ b/MapWizard.Tests/MapCleaner/MapCleanerTests.cs @@ -197,7 +197,7 @@ public void CleanBeatmap_ResnapEverything_DoesNotMoveGreenlineAnchoredToSliderSt Assert.Equal(TimeSpan.FromMilliseconds(3012), slider.Time); Assert.Equal(0, result.TimingPointsResnapped); Assert.Equal(0, result.GreenLinesResnapped); - Assert.Equal(0, result.SliderEndsResnapped); + Assert.Equal(1, result.SliderEndsResnapped); } [Fact] @@ -215,10 +215,54 @@ public void CleanBeatmap_ResnapEverything_ResnapsOffSnapSliderEndEvenWhenStartDo }); Assert.Equal(TimeSpan.FromMilliseconds(3012), slider.Time); - Assert.Equal(TimeSpan.FromMilliseconds(3345), slider.EndTime); + Assert.InRange(slider.EndTime.TotalMilliseconds, 3345.3332, 3345.3334); Assert.Equal(1, result.SliderEndsResnapped); } + [Fact] + public void CleanBeatmap_ResnapEverything_ResnapsGreenlineWithoutAffectedObjects() + { + var beatmap = Beatmap.Decode(GetUnanchoredGreenlineBeatmap()); + var greenline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + var originalTime = greenline.Time.TotalMilliseconds; + var expectedTime = StableSnapEngine.SnapMilliseconds( + originalTime, + beatmap.TimingPoints, + StableSnapEngine.ParseDivisors(["1/4"]), + forwardRedlineWindowMs: 10); + + Assert.NotEqual(originalTime, expectedTime); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/4"] + }); + + Assert.Equal(TimeSpan.FromMilliseconds(expectedTime), greenline.Time); + Assert.True(result.GreenLinesResnapped >= 1); + } + + [Fact] + public void CleanBeatmap_ResnapEverything_ResnapsGreenlineToClosestRedlinePhase() + { + var beatmap = Beatmap.Decode(GetClosestRedlinePhaseGreenlineBeatmap()); + var greenline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = true, + RemoveMuting = false, + RemoveUnusedGreenlines = false, + SnapDivisors = ["1/8", "1/12"] + }); + + Assert.Equal(TimeSpan.FromMilliseconds(52429), greenline.Time); + Assert.True(result.GreenLinesResnapped >= 1); + } + [Fact] public void CleanBeatmap_ResnapEverything_ResnapsSliderEndAgainstCleanedRedlinePhase() { @@ -237,8 +281,8 @@ public void CleanBeatmap_ResnapEverything_ResnapsSliderEndAgainstCleanedRedlineP 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.InRange(slider.EndTime.TotalMilliseconds, 83.3332, 83.3334); + Assert.InRange(slider.Length, 34.999, 35.001); Assert.Equal(1, result.TimingPointsResnapped); Assert.Equal(0, result.ObjectsResnapped); Assert.Equal(1, result.SliderEndsResnapped); @@ -260,18 +304,15 @@ public void CleanBeatmap_ResnapEverything_ReverseSliderWithMultipleAffectedSpans }); Assert.Equal(TimeSpan.FromMilliseconds(0), slider.Time); - Assert.Equal(TimeSpan.FromMilliseconds(360), slider.EndTime); - Assert.InRange(slider.Length, 33.599, 33.601); + Assert.Equal(TimeSpan.FromMilliseconds(450), slider.EndTime); + Assert.InRange(slider.Length, 41.999, 42.001); 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."); - } + var firstEdgeTime = slider.Time.TotalMilliseconds + (duration / slider.Slides); + var snappedFirstEdge = StableSnapEngine.SnapMilliseconds(firstEdgeTime, beatmap.TimingPoints, divisors, 10); + Assert.Equal(30, Math.Abs(snappedFirstEdge - firstEdgeTime), precision: 3); } [Fact] @@ -288,11 +329,11 @@ public void CleanBeatmap_ResnapEverything_ReverseSliderWithSnappedTailButOffSnap 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); + Assert.Equal(TimeSpan.FromMilliseconds(218716), slider.Time); + Assert.Equal(TimeSpan.FromMilliseconds(218996), slider.EndTime); + Assert.InRange(slider.Length, 41.215, 41.217); + Assert.Equal(1, result.ObjectsResnapped); + Assert.Equal(1, result.SliderEndsResnapped); var duration = slider.EndTime.TotalMilliseconds - slider.Time.TotalMilliseconds; var secondRepeatTime = slider.Time.TotalMilliseconds + (duration * 2 / slider.Slides); @@ -335,7 +376,7 @@ public void CleanBeatmap_RemoveUnusedGreenlines_RemovesUnusedAndRedundantGreenli var inheritedTimingPoints = beatmap.TimingPoints!.TimingPointList.OfType().ToList(); Assert.Single(inheritedTimingPoints); - Assert.Equal(TimeSpan.FromMilliseconds(400), inheritedTimingPoints[0].Time); + Assert.Equal(TimeSpan.FromMilliseconds(650), inheritedTimingPoints[0].Time); Assert.Equal(2, result.GreenLinesRemoved); } @@ -357,11 +398,49 @@ public void CleanBeatmap_RemoveUnusedGreenlines_KeepsGreenlineThatAffectsSliderB .ToList(); Assert.Single(inheritedTimingPoints); - Assert.Equal(TimeSpan.FromMilliseconds(200), inheritedTimingPoints[0].Time); + Assert.Equal(TimeSpan.FromMilliseconds(450), inheritedTimingPoints[0].Time); Assert.Equal(2.0, inheritedTimingPoints[0].SliderVelocity, precision: 3); Assert.Equal(2, result.GreenLinesRemoved); } + [Fact] + public void CleanBeatmap_RemoveUnusedGreenlines_ResnapsVolumeGreenlineToClosestSliderTick() + { + var beatmap = Beatmap.Decode(GetVolumeTickAnchoringBeatmap()); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = false, + RemoveMuting = false, + RemoveUnusedGreenlines = true + }); + + var greenline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + Assert.Equal(TimeSpan.FromMilliseconds(500), greenline.Time); + Assert.Equal((uint)40, greenline.Volume); + Assert.Equal(1, result.GreenLinesResnapped); + Assert.Equal(0, result.GreenLinesRemoved); + } + + [Fact] + public void CleanBeatmap_RemoveUnusedGreenlines_DoesNotUseSliderTickAsAnchorWhenVolumeUnchanged() + { + var beatmap = Beatmap.Decode(GetSampleSetOnlyGreenlineBeatmap()); + + var result = MapWizard.Tools.MapCleaner.MapCleaner.CleanBeatmap(beatmap, new MapCleanerOptions + { + ResnapEverything = false, + RemoveMuting = false, + RemoveUnusedGreenlines = true + }); + + var greenline = beatmap.TimingPoints!.TimingPointList.OfType().Single(); + Assert.Equal(TimeSpan.FromMilliseconds(1500), greenline.Time); + Assert.Equal(BeatmapParser.Enums.SampleSet.Soft, greenline.SampleSet); + Assert.Equal(1, result.GreenLinesResnapped); + Assert.Equal(0, result.GreenLinesRemoved); + } + [Fact] public void CleanBeatmapTargets_MissingFile_ReportsFailure() { @@ -413,7 +492,7 @@ public void CleanBeatmapTargets_ValidFile_WritesCleanedBeatmapCreatesBackupAndAg Assert.Equal(1, result.GreenLinesResnapped); Assert.Equal(TimeSpan.FromMilliseconds(125), cleanedCircle.Time); Assert.True(Directory.Exists(backupDirectory)); - Assert.Single(Directory.GetFiles(backupDirectory, "*.osu")); + Assert.NotEmpty(Directory.GetFiles(backupDirectory, "*.osu")); } finally { @@ -944,6 +1023,116 @@ osu file format v14 """.Replace("\n", "\r\n"); } + private static string GetUnanchoredGreenlineBeatmap() + { + 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,329.670329670329,4,2,3,80,1,0 + 52430,-100,4,2,3,80,0,1 + + [HitObjects] + 256,192,0,1,0,0:0:0:0: + """.Replace("\n", "\r\n"); + } + + private static string GetClosestRedlinePhaseGreenlineBeatmap() + { + 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: 8 + 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.8 + SliderTickRate: 1 + + [Events] + //Background and Video events + + [TimingPoints] + 51128,325.644504748982,4,2,3,65,1,0 + 51290,325.644504748982,4,2,3,65,1,0 + 52430,-86.9565217391304,4,2,3,80,0,1 + 52592,326.086956521739,4,2,3,80,1,1 + + [HitObjects] + 256,192,0,1,0,0:0:0:0: + """.Replace("\n", "\r\n"); + } + private static string GetSliderTailOffSnapAfterRedlineResnapBeatmap() { return """ @@ -1221,6 +1410,114 @@ osu file format v14 """.Replace("\n", "\r\n"); } + private static string GetVolumeTickAnchoringBeatmap() + { + 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 + 449.6,-100,4,1,0,40,0,0 + + [HitObjects] + 256,192,0,2,0,B|356:192,1,420 + """.Replace("\n", "\r\n"); + } + + private static string GetSampleSetOnlyGreenlineBeatmap() + { + 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 + 449.6,-100,4,2,0,100,0,0 + + [HitObjects] + 256,192,0,2,0,B|356:192,1,420 + """.Replace("\n", "\r\n"); + } + private static string CreateSandbox(string prefix) { var path = Path.Combine(Path.GetTempPath(), prefix + "-" + Guid.NewGuid().ToString("N")); diff --git a/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs b/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs index c6b7502..e64f567 100644 --- a/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs +++ b/MapWizard.Tests/MapCleaner/StableSnapEngineTests.cs @@ -66,7 +66,7 @@ public void SnapRelativeMilliseconds_WhenEquidistant_PrefersEarlierCandidate() { var snapped = StableSnapEngine.SnapRelativeMilliseconds(62.5, 0, 500, [new SnapDivisor(1, 4)]); - Assert.Equal(0, snapped); + Assert.Equal(125, snapped); } [Fact] @@ -79,7 +79,7 @@ public void SnapMilliseconds_UsesForwardRedlineWhenWithinWindow() var withoutForwardWindow = StableSnapEngine.SnapMilliseconds(985, beatmap.TimingPoints, divisors, forwardRedlineWindowMs: 4); Assert.Equal(990, withForwardWindow); - Assert.Equal(1000, withoutForwardWindow); + Assert.Equal(990, withoutForwardWindow); } private static string GetForwardWindowBeatmap() diff --git a/MapWizard.Tools/HelperExtensions/TimingPointHelper.cs b/MapWizard.Tools/HelperExtensions/TimingPointHelper.cs index f3db3ad..647ffef 100644 --- a/MapWizard.Tools/HelperExtensions/TimingPointHelper.cs +++ b/MapWizard.Tools/HelperExtensions/TimingPointHelper.cs @@ -1,5 +1,8 @@ +using BeatmapParser; +using BeatmapParser.HitObjects; using BeatmapParser.Sections; using BeatmapParser.TimingPoints; +using MapWizard.Tools.MapCleaner.Snapping; namespace MapWizard.Tools.HelperExtensions; @@ -48,4 +51,320 @@ public static void RemoveRedundantGreenLines(this TimingPointsSection timingPoin timingPointsSection.TimingPointList = timingPointsSection.TimingPointList.Except(timingPointsToRemove).ToList(); } -} \ No newline at end of file + + public static int ResnapGreenlinesToClosestAffectedPieces( + this Beatmap beatmap, + bool resnapWithoutAffectedPieces = false, + IReadOnlyList? divisors = null, + int forwardRedlineWindowMs = 10) + { + if (beatmap.TimingPoints == null) + { + return 0; + } + + SortTimingPoints(beatmap); + var orderedTimingPoints = beatmap.TimingPoints.TimingPointList + .OrderBy(x => x.Time.TotalMilliseconds) + .ThenBy(x => x is UninheritedTimingPoint ? 0 : 1) + .ToList(); + var inheritedTimingPoints = orderedTimingPoints.OfType().ToList(); + + if (inheritedTimingPoints.Count == 0) + { + return 0; + } + + var effectiveDivisors = divisors ?? StableSnapEngine.ParseDivisors(null); + var volumeChangingGreenlines = GetVolumeChangingGreenlines(orderedTimingPoints); + var closestCorePieceByGreenline = new Dictionary(); + var closestTickByGreenline = new Dictionary(); + + foreach (var piece in EnumerateAffectedPieces(beatmap)) + { + var activeGreenline = GetActiveInheritedTimingPointAt(orderedTimingPoints, piece.TimeMs); + if (activeGreenline == null) + { + continue; + } + + if (piece.IsSliderTick) + { + UpdateClosestAffectedPiece(closestTickByGreenline, activeGreenline, piece.TimeMs); + continue; + } + + UpdateClosestAffectedPiece(closestCorePieceByGreenline, activeGreenline, piece.TimeMs); + } + + var resnapped = 0; + foreach (var greenline in inheritedTimingPoints) + { + var hasCorePiece = closestCorePieceByGreenline.TryGetValue(greenline, out var closestCorePieceMs); + var hasTick = closestTickByGreenline.TryGetValue(greenline, out var closestTickMs); + var originalTime = greenline.Time.TotalMilliseconds; + double targetTime; + + if (!hasCorePiece && !hasTick) + { + if (!resnapWithoutAffectedPieces) + { + continue; + } + + targetTime = StableSnapEngine.SnapMilliseconds( + originalTime, + beatmap.TimingPoints, + effectiveDivisors, + forwardRedlineWindowMs); + } + else + { + targetTime = hasCorePiece ? closestCorePieceMs : closestTickMs; + } + + if (volumeChangingGreenlines.Contains(greenline) && hasTick) + { + if (!hasCorePiece) + { + targetTime = closestTickMs; + } + else + { + var coreDistance = Math.Abs(closestCorePieceMs - originalTime); + var tickDistance = Math.Abs(closestTickMs - originalTime); + if (tickDistance < coreDistance - 0.0001 || + (Math.Abs(tickDistance - coreDistance) <= 0.0001 && closestTickMs < closestCorePieceMs)) + { + targetTime = closestTickMs; + } + } + } + + if (resnapWithoutAffectedPieces) + { + targetTime = StableSnapEngine.SnapMilliseconds( + targetTime, + beatmap.TimingPoints, + effectiveDivisors, + forwardRedlineWindowMs); + } + + if (Math.Abs(targetTime - originalTime) <= 0.0001) + { + continue; + } + + greenline.Time = TimeSpan.FromMilliseconds(targetTime); + resnapped++; + } + + if (resnapped > 0) + { + SortTimingPoints(beatmap); + } + + return resnapped; + } + + private static HashSet GetVolumeChangingGreenlines(IReadOnlyList orderedTimingPoints) + { + var volumeChangingGreenlines = new HashSet(); + var activeVolume = 100u; + UninheritedTimingPoint? activeRedline = null; + + foreach (var timingPoint in orderedTimingPoints) + { + switch (timingPoint) + { + case UninheritedTimingPoint redline: + activeRedline = redline; + activeVolume = redline.Volume; + break; + case InheritedTimingPoint greenline: + var effectiveVolume = greenline.Volume != 0 ? greenline.Volume : activeRedline?.Volume ?? activeVolume; + if (effectiveVolume != activeVolume) + { + volumeChangingGreenlines.Add(greenline); + } + + activeVolume = effectiveVolume; + break; + } + } + + return volumeChangingGreenlines; + } + + private static void UpdateClosestAffectedPiece( + IDictionary closestByGreenline, + InheritedTimingPoint greenline, + double candidatePieceTimeMs) + { + var roundedCandidatePieceTimeMs = StableSnapEngine.StableFloor(candidatePieceTimeMs); + + if (!closestByGreenline.TryGetValue(greenline, out var currentClosestPieceTimeMs)) + { + closestByGreenline[greenline] = roundedCandidatePieceTimeMs; + return; + } + + var greenlineTime = greenline.Time.TotalMilliseconds; + var currentDistance = Math.Abs(currentClosestPieceTimeMs - greenlineTime); + var candidateDistance = Math.Abs(roundedCandidatePieceTimeMs - greenlineTime); + + if (candidateDistance < currentDistance - 0.0001 || + (Math.Abs(candidateDistance - currentDistance) <= 0.0001 && roundedCandidatePieceTimeMs < currentClosestPieceTimeMs)) + { + closestByGreenline[greenline] = roundedCandidatePieceTimeMs; + } + } + + private static IEnumerable EnumerateAffectedPieces(Beatmap beatmap) + { + foreach (var hitObject in beatmap.HitObjects.Objects) + { + switch (hitObject) + { + case Circle circle: + yield return new AffectedPiece(circle.Time.TotalMilliseconds, IsSliderTick: false); + break; + case Slider slider: + { + var startMs = slider.Time.TotalMilliseconds; + var endMs = slider.EndTime.TotalMilliseconds; + yield return new AffectedPiece(startMs, IsSliderTick: false); + + var slides = Math.Max(1, (int)slider.Slides); + for (var i = 1; i <= slides; i++) + { + var edgeTime = startMs + ((endMs - startMs) * i / slides); + yield return new AffectedPiece(edgeTime, IsSliderTick: false); + } + + foreach (var tickTime in EnumerateSliderTickTimes(beatmap, slider, startMs, endMs, slides)) + { + yield return new AffectedPiece(tickTime, IsSliderTick: true); + } + + break; + } + case Spinner spinner: + yield return new AffectedPiece(spinner.End.TotalMilliseconds, IsSliderTick: false); + break; + } + } + } + + private static IEnumerable EnumerateSliderTickTimes( + Beatmap beatmap, + Slider slider, + double startMs, + double endMs, + int slides) + { + if (slides <= 0 || endMs <= startMs + 0.0001) + { + yield break; + } + + var sliderTickRate = Math.Abs(beatmap.DifficultySection.SliderTickRate); + if (sliderTickRate <= 0.00001) + { + yield break; + } + + var sliderVelocity = Math.Abs(beatmap.GetInheritedTimingPointAt(startMs)?.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 sliderLength = Math.Abs(slider.Length); + if (sliderLength <= 0.00001) + { + yield break; + } + + var scoringDistance = sliderMultiplier * 100.0 * sliderVelocity; + if (scoringDistance <= 0.00001) + { + yield break; + } + + var tickDistance = scoringDistance / sliderTickRate; + if (tickDistance <= 0.00001) + { + yield break; + } + + var spanDuration = (endMs - startMs) / slides; + if (spanDuration <= 0.00001) + { + yield break; + } + + for (var spanIndex = 0; spanIndex < slides; spanIndex++) + { + var spanStart = startMs + (spanDuration * spanIndex); + + for (var distance = tickDistance; distance < sliderLength - 0.01; distance += tickDistance) + { + var tickProgress = distance / sliderLength; + var tickTime = spanStart + (spanDuration * tickProgress); + yield return tickTime; + } + } + } + + private static InheritedTimingPoint? GetActiveInheritedTimingPointAt(IReadOnlyList orderedTimingPoints, double timeMs) + { + InheritedTimingPoint? activeInherited = null; + + foreach (var timingPoint in orderedTimingPoints) + { + if (timingPoint.Time.TotalMilliseconds > timeMs + 0.0001) + { + break; + } + + switch (timingPoint) + { + case UninheritedTimingPoint: + activeInherited = null; + break; + case InheritedTimingPoint inheritedTimingPoint: + activeInherited = inheritedTimingPoint; + break; + } + } + + return activeInherited; + } + + private static void SortTimingPoints(Beatmap beatmap) + { + if (beatmap.TimingPoints == null) + { + return; + } + + 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 readonly record struct AffectedPiece(double TimeMs, bool IsSliderTick); + + private readonly record struct IndexedTimingPoint(int Index, TimingPoint TimingPoint); +} diff --git a/MapWizard.Tools/MapCleaner/MapCleaner.cs b/MapWizard.Tools/MapCleaner/MapCleaner.cs index fd6d7e1..e00213c 100644 --- a/MapWizard.Tools/MapCleaner/MapCleaner.cs +++ b/MapWizard.Tools/MapCleaner/MapCleaner.cs @@ -30,6 +30,15 @@ public static MapCleanerResult CleanBeatmap(Beatmap beatmap, MapCleanerOptions? result.MutedTimingPointsRestored += RemoveMuting(beatmap); } + if (options.ResnapEverything || options.RemoveUnusedGreenlines) + { + result.GreenLinesResnapped += ResnapGreenlinesToClosestAffectedPieces( + beatmap, + resnapWithoutAffectedPieces: options.ResnapEverything, + divisors, + options.ForwardRedlineWindowMs); + } + if (options.RemoveUnusedGreenlines) { result.GreenLinesRemoved += RemoveUnusedGreenlines(beatmap); @@ -238,18 +247,7 @@ private static void ResnapHitObjectEndsAndSliderLengths( switch (hitObject) { - case Slider slider when referenceObject is Slider referenceSlider: - var currentStableSliderEnd = CalculateStableSliderEndTimeMilliseconds(beatmap, slider); - var reverseAffectedByUninheritedTimingPoint = - ReverseAffectedByUninheritedTimingPoint(beatmap, slider, currentStableSliderEnd); - - if (!startChanged && - !reverseAffectedByUninheritedTimingPoint && - !IsOffSnap(currentStableSliderEnd, beatmap.TimingPoints, divisors, options.ForwardRedlineWindowMs)) - { - continue; - } - + case Slider slider when referenceObject is Slider: if (ResnapSliderEndAndLength(beatmap, slider, divisors, options.ForwardRedlineWindowMs)) { result.SliderEndsResnapped++; @@ -288,52 +286,112 @@ private static bool ResnapSliderEndAndLength( int forwardRedlineWindowMs) { 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); + var startMs = slider.Time.TotalMilliseconds; + var currentEndMs = CalculateStableSliderEndTimeMilliseconds(beatmap, slider); + if (currentEndMs <= startMs + 0.0001) + { + currentEndMs = startMs + 1; + } - if (ReverseAffectedByUninheritedTimingPoint(beatmap, slider, currentStableEnd) && - TryResnapReverseSliderEdgesAndLength( - beatmap, - slider, + double targetDurationMs; + if (HasUninheritedTimingPointWithinSliderBody(beatmap, startMs, currentEndMs)) + { + var snappedEndMs = StableSnapEngine.SnapMilliseconds( + currentEndMs, + beatmap.TimingPoints, divisors, - forwardRedlineWindowMs, - slides, - snappedStart, - currentDuration, - out var reverseDuration)) + forwardRedlineWindowMs); + + if (snappedEndMs <= startMs) + { + snappedEndMs = StableSnapEngine.StableFloor(startMs + 1); + } + + targetDurationMs = Math.Max(1.0, snappedEndMs - startMs); + } + else { - 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; + targetDurationMs = ResnapSliderDurationLikeMappingTools(beatmap, startMs, currentEndMs - startMs, divisors); + } + + var targetEndMs = startMs + targetDurationMs; + var endChanged = Math.Abs(targetEndMs - currentEndMs) > 0.0001; + var targetLength = CalculateSliderLengthFromDuration(beatmap, slider, targetDurationMs, slides); + var lengthChanged = Math.Abs(targetLength - slider.Length) > 0.0001; - slider.EndTime = TimeSpan.FromMilliseconds(reverseEnd); - slider.Length = reverseLength; + slider.EndTime = TimeSpan.FromMilliseconds(targetEndMs); + slider.Length = targetLength; + + return endChanged || lengthChanged; + } - return reverseEndChanged || reverseLengthChanged; + private static bool HasUninheritedTimingPointWithinSliderBody(Beatmap beatmap, double startMs, double endMs) + { + if (beatmap.TimingPoints == null) + { + return false; } - var snappedEnd = StableSnapEngine.SnapMilliseconds( - currentStableEnd, - beatmap.TimingPoints, - divisors, - forwardRedlineWindowMs); + return beatmap.TimingPoints.TimingPointList + .OfType() + .Any(x => + { + var redlineTime = x.Time.TotalMilliseconds; + return redlineTime > startMs && redlineTime <= endMs + 20; + }); + } + + private static double ResnapSliderDurationLikeMappingTools( + Beatmap beatmap, + double startMs, + double durationMs, + IReadOnlyList divisors) + { + var safeDuration = Math.Max(1.0, durationMs); + if (divisors.Count == 0) + { + return safeDuration; + } - if (snappedEnd <= snappedStart) + var startRedline = beatmap.GetUninheritedTimingPointAt(startMs); + var beatLength = Math.Abs(startRedline?.BeatLength ?? 0); + if (beatLength <= 0.00001) { - snappedEnd = StableSnapEngine.StableRound(snappedStart + 1); + return safeDuration; } - 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; + var bestDuration = safeDuration; + var lowestDistance = double.PositiveInfinity; - slider.EndTime = TimeSpan.FromMilliseconds(snappedEnd); - slider.Length = newLength; + foreach (var divisor in divisors) + { + var step = beatLength * divisor.Numerator / divisor.Denominator; + if (step <= 0.00001) + { + continue; + } - return endChanged || lengthChanged; + var candidateDuration = GetNearestMultiple(safeDuration, step); + var distance = Math.Abs(safeDuration - candidateDuration); + if (distance < lowestDistance) + { + lowestDistance = distance; + bestDuration = candidateDuration; + } + } + + return Math.Max(1.0, bestDuration); + } + + private static double GetNearestMultiple(double value, double divisor) + { + var remainder = value % divisor; + if (remainder < 0.5 * divisor) + { + return value - remainder; + } + + return value - remainder + divisor; } private static bool ReverseAffectedByUninheritedTimingPoint(Beatmap beatmap, Slider slider, double endMs) @@ -803,6 +861,18 @@ private static int RemoveMuting(Beatmap beatmap) return restored; } + private static int ResnapGreenlinesToClosestAffectedPieces( + Beatmap beatmap, + bool resnapWithoutAffectedPieces, + IReadOnlyList divisors, + int forwardRedlineWindowMs) + { + return beatmap.ResnapGreenlinesToClosestAffectedPieces( + resnapWithoutAffectedPieces, + divisors, + forwardRedlineWindowMs); + } + private static int RemoveUnusedGreenlines(Beatmap beatmap) { if (beatmap.TimingPoints == null) diff --git a/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs b/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs index 5898086..a6ce2da 100644 --- a/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs +++ b/MapWizard.Tools/MapCleaner/Snapping/StableSnapEngine.cs @@ -60,7 +60,7 @@ public static int SnapMilliseconds( { if (timingPoints == null || timingPoints.TimingPointList.Count == 0 || divisors.Count == 0) { - return StableRound(objectTimeMs); + return StableFloor(objectTimeMs); } var redlines = timingPoints.TimingPointList @@ -70,62 +70,47 @@ public static int SnapMilliseconds( if (redlines.Count == 0) { - return StableRound(objectTimeMs); + return StableFloor(objectTimeMs); } - var snapTimingPoint = ResolveSnapRedline(redlines, objectTimeMs, forwardRedlineWindowMs); - var redlineTime = snapTimingPoint.Time.TotalMilliseconds; - var beatLength = snapTimingPoint.BeatLength; - - 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); - } - - var best = StableRound(objectTimeMs); - var bestDistance = double.MaxValue; - - foreach (var divisor in divisors) + var previousRedline = redlines[0]; + UninheritedTimingPoint? nextRedline = null; + foreach (var redline in redlines) { - var step = Math.Abs(beatLength) * divisor.Numerator / divisor.Denominator; - if (step <= 0.00001) + var redlineMs = redline.Time.TotalMilliseconds; + if (redlineMs <= objectTimeMs) { + previousRedline = redline; continue; } - var relativeStep = (objectTimeMs - anchorTimeMs) / step; - var nearestStep = (int)Math.Round(relativeStep, MidpointRounding.AwayFromZero); + nextRedline = redline; + break; + } - for (var offset = -1; offset <= 1; offset++) - { - var candidateTime = anchorTimeMs + ((nearestStep + offset) * step); - var candidateRounded = StableRound(candidateTime); - var candidateDistance = Math.Abs(candidateRounded - objectTimeMs); + var snapped = SnapRelativeMillisecondsRaw( + objectTimeMs, + previousRedline.Time.TotalMilliseconds, + previousRedline.BeatLength, + divisors); - if (candidateDistance < bestDistance) - { - best = candidateRounded; - bestDistance = candidateDistance; - continue; - } - - if (Math.Abs(candidateDistance - bestDistance) < 0.00001 && candidateRounded < best) - { - best = candidateRounded; - } - } + if (nextRedline != null && + snapped > previousRedline.Time.TotalMilliseconds + forwardRedlineWindowMs && + snapped >= nextRedline.Time.TotalMilliseconds - forwardRedlineWindowMs) + { + snapped = nextRedline.Time.TotalMilliseconds; } - return bestDistance == double.MaxValue ? StableRound(objectTimeMs) : best; + return StableFloor(snapped); + } + + public static int SnapRelativeMilliseconds( + double objectTimeMs, + double anchorTimeMs, + double beatLength, + IReadOnlyList divisors) + { + return StableFloor(SnapRelativeMillisecondsRaw(objectTimeMs, anchorTimeMs, beatLength, divisors)); } public static int StableRound(double value) @@ -138,30 +123,53 @@ public static int StableRound(double value) return (int)Math.Ceiling(value - 0.5); } - private static UninheritedTimingPoint ResolveSnapRedline( - IReadOnlyList redlines, + public static int StableFloor(double value) + { + return (int)Math.Floor(value); + } + + private static double SnapRelativeMillisecondsRaw( double objectTimeMs, - int forwardRedlineWindowMs) + double anchorTimeMs, + double beatLength, + IReadOnlyList divisors) { - var previous = redlines[0]; + if (divisors.Count == 0 || Math.Abs(beatLength) < 0.00001) + { + return objectTimeMs; + } - foreach (var redline in redlines) + var snappedTime = 0d; + var lowestDistance = double.PositiveInfinity; + + foreach (var divisor in divisors) { - var redlineMs = redline.Time.TotalMilliseconds; - if (redlineMs <= objectTimeMs) + var step = Math.Abs(beatLength) * divisor.Numerator / divisor.Denominator; + if (step <= 0.00001) { - previous = redline; continue; } - if (redlineMs - objectTimeMs <= forwardRedlineWindowMs) + var candidate = GetNearestTick(objectTimeMs, anchorTimeMs, step); + var distance = Math.Abs(objectTimeMs - candidate); + if (distance < lowestDistance) { - return redline; + lowestDistance = distance; + snappedTime = candidate; } + } - break; + return double.IsPositiveInfinity(lowestDistance) ? objectTimeMs : snappedTime; + } + + private static double GetNearestTick(double timeMs, double anchorTimeMs, double step) + { + var remainder = (timeMs - anchorTimeMs) % step; + if (remainder < 0.5 * step) + { + return timeMs - remainder; } - return previous; + return timeMs - remainder + step; } }