diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs index 99598557a6..b4f084a07c 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs @@ -22,6 +22,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCase("convert-samples")] [TestCase("mania-samples")] + [TestCase("mania-slider")] // e.g. second and fourth notes of https://osu.ppy.sh/beatmapsets/73883#mania/216407 [TestCase("slider-convert-samples")] public void Test(string name) => base.Test(name); @@ -32,6 +33,7 @@ namespace osu.Game.Rulesets.Mania.Tests StartTime = hitObject.StartTime, EndTime = hitObject.GetEndTime(), Column = ((ManiaHitObject)hitObject).Column, + PlaySlidingSamples = hitObject is HoldNote holdNote && holdNote.PlaySlidingSamples, Samples = getSampleNames(hitObject.Samples), NodeSamples = getNodeSampleNames((hitObject as HoldNote)?.NodeSamples) }; @@ -57,12 +59,14 @@ namespace osu.Game.Rulesets.Mania.Tests public double StartTime; public double EndTime; public int Column; + public bool PlaySlidingSamples; public IList Samples; public IList> NodeSamples; public bool Equals(SampleConvertValue other) => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) && Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience) + && PlaySlidingSamples == other.PlaySlidingSamples && samplesEqual(Samples, other.Samples) && nodeSamplesEqual(NodeSamples, other.NodeSamples); diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json index 4d298bb671..273dd33452 100644 --- a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json +++ b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json @@ -5,6 +5,7 @@ "StartTime": 1000.0, "EndTime": 2750.0, "Column": 1, + "PlaySlidingSamples": true, "NodeSamples": [ ["Gameplay/normal-hitnormal"], ["Gameplay/soft-hitnormal"], @@ -15,6 +16,7 @@ "StartTime": 1875.0, "EndTime": 2750.0, "Column": 0, + "PlaySlidingSamples": true, "NodeSamples": [ ["Gameplay/soft-hitnormal"], ["Gameplay/drum-hitnormal"] diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json index fd0c0cad60..eb0c5df10b 100644 --- a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json +++ b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json @@ -5,6 +5,7 @@ "StartTime": 500.0, "EndTime": 1500.0, "Column": 0, + "PlaySlidingSamples": false, "NodeSamples": [ ["Gameplay/normal-hitnormal"], [] @@ -17,6 +18,7 @@ "StartTime": 2000.0, "EndTime": 3000.0, "Column": 2, + "PlaySlidingSamples": false, "NodeSamples": [ ["Gameplay/drum-hitnormal"], [] diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider-expected-conversion.json b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider-expected-conversion.json new file mode 100644 index 0000000000..bf097f589e --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider-expected-conversion.json @@ -0,0 +1,18 @@ +{ + "Mappings": [{ + "StartTime": 500.0, + "Objects": [{ + "StartTime": 500.0, + "EndTime": 2500, + "Column": 2, + "PlaySlidingSamples": true, + "NodeSamples": [ + ["Gameplay/soft-hitnormal"], + ["Gameplay/soft-hitnormal"], + ["Gameplay/soft-hitnormal"], + ["Gameplay/soft-hitnormal"] + ], + "Samples": ["Gameplay/soft-hitnormal"] + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider.osu b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider.osu new file mode 100644 index 0000000000..c6e44ab9bf --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider.osu @@ -0,0 +1,29 @@ +osu file format v5 + +[General] +StackLeniency: 0.7 +Mode: 3 + +[Difficulty] +HPDrainRate:2 +CircleSize:5 +OverallDifficulty:2 +SliderMultiplier:1 +SliderTickRate:2 + +[Events] +//Background and Video events +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Failing) +//Storyboard Layer 2 (Passing) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples +//Background Colour Transformations +3,100,163,162,255 + +[TimingPoints] +355,476.190476190476,4,2,1,60,1,0 + +[HitObjects] +256,352,500,2,0,L|256:208,3,140 diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PassThroughPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PassThroughPatternGenerator.cs index efeb99e8b4..2c6c4d1a5c 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PassThroughPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PassThroughPatternGenerator.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Utils; @@ -30,12 +32,18 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (HitObject is IHasDuration endTimeData) { + // despite the beatmap originally being made for mania, if the object is parsed as a slider rather than a hold, sliding samples should still be played. + // this is seemingly only possible to achieve by modifying the .osu file directly, but online beatmaps that do that exist + // (see second and fourth notes of https://osu.ppy.sh/beatmapsets/73883#mania/216407) + bool playSlidingSamples = (HitObject is IHasLegacyHitObjectType hasType && hasType.LegacyType == LegacyHitObjectType.Slider) || HitObject is IHasPath; + pattern.Add(new HoldNote { StartTime = HitObject.StartTime, Duration = endTimeData.Duration, Column = column, Samples = HitObject.Samples, + PlaySlidingSamples = playSlidingSamples, NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? HoldNote.CreateDefaultNodeSamples(HitObject) }); } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SliderPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SliderPatternGenerator.cs index e539baa94a..dee990f842 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SliderPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/SliderPatternGenerator.cs @@ -521,6 +521,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy Duration = endTime - startTime, Column = column, Samples = HitObject.Samples, + PlaySlidingSamples = true, NodeSamples = nodeSamplesAt(startTime) }; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs index d1912e3690..cc407a890f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs @@ -63,6 +63,7 @@ namespace osu.Game.Rulesets.Mania.Mods StartTime = locations[i].startTime, Duration = duration, NodeSamples = new List> { locations[i].samples, Array.Empty() } + // intentionally don't play sliding samples here, it doesn't work in this mod. }); } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs index 143a5f1bdc..d664567a63 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs @@ -80,7 +80,9 @@ namespace osu.Game.Rulesets.Mania.Mods StartTime = hold.StartTime; Duration = hold.Duration; Column = hold.Column; + Samples = hold.Samples; NodeSamples = hold.NodeSamples; + PlaySlidingSamples = hold.PlaySlidingSamples; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 23c062164e..210cd2a103 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -355,7 +355,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private void updateSlidingSample(ValueChangedEvent tracking) { - if (tracking.NewValue) + if (tracking.NewValue && HitObject.PlaySlidingSamples) slidingSample?.Play(); else slidingSample?.Stop(); diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index 98060dd226..15c570e34a 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -86,6 +86,11 @@ namespace osu.Game.Rulesets.Mania.Objects /// public HoldNoteBody Body { get; protected set; } + /// + /// Whether sliding samples should be played when held. + /// + public bool PlaySlidingSamples { get; init; } + public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset; protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs new file mode 100644 index 0000000000..45c5bf3985 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs @@ -0,0 +1,222 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Edit.Checks; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests.Editor.Checks +{ + [TestFixture] + public class CheckTaikoInconsistentSkipBarLineTest + { + private CheckTaikoInconsistentSkipBarLine check = null!; + + [SetUp] + public void Setup() + { + check = new CheckTaikoInconsistentSkipBarLine(); + } + + [Test] + public void TestConsistentOmitFirstBarLine() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, false), (2000.0, true) } // Same settings + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestInconsistentOmitFirstBarLine() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, false) } // Different settings + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.All(issue => issue.Template is CheckTaikoInconsistentSkipBarLine.IssueTemplateInconsistentOmitFirstBarLine)); + Assert.That(issues[0].Time, Is.EqualTo(1000.0)); + Assert.That(issues[1].Time, Is.EqualTo(2000.0)); + } + + [Test] + public void TestPartiallyInconsistentOmitFirstBarLine() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true), (3000.0, false) }, // Reference + new[] { (1000.0, false), (2000.0, false), (3000.0, false) } // Only second differs + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckTaikoInconsistentSkipBarLine.IssueTemplateInconsistentOmitFirstBarLine); + Assert.That(issues[0].Time, Is.EqualTo(2000.0)); + } + + [Test] + public void TestSingleDifficulty() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) } // Only one difficulty + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestNonTaikoBeatmaps() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, false) } // Different settings + ); + + // Make both beatmaps non-taiko + beatmaps[0].BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + beatmaps[1].BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMixedRulesets() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, false) } // Different settings + ); + + // Make reference taiko, other non-taiko + beatmaps[0].BeatmapInfo.Ruleset = new TaikoRuleset().RulesetInfo; + beatmaps[1].BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMissingTimingPoints() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference has 2 points + new[] { (1000.0, false) } // Other has only 1 point (missing 2000.0) + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + // Should only check the existing timing point at 1000.0 (consistent, no issue) + // The missing 2000.0 point should be ignored by this check + Assert.That(issues, Is.Empty); + } + + [Test] + public void TestExtraTimingPoints() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false) }, // Reference has 1 point + new[] { (1000.0, false), (2000.0, true) } // Other has extra point + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + // Should only check the existing timing point at 1000.0 (consistent, no issue) + // The extra 2000.0 point should be ignored by this check + Assert.That(issues, Is.Empty); + } + + [Test] + public void TestMultipleDifficultiesWithInconsistencies() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, true) }, // First differs + new[] { (1000.0, false), (2000.0, false) } // Second differs + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + // Should have issues for both other difficulties + Assert.That(issues, Has.Count.EqualTo(2)); // 1000.0 from diff2, 2000.0 from diff3 + Assert.That(issues.All(issue => issue.Template is CheckTaikoInconsistentSkipBarLine.IssueTemplateInconsistentOmitFirstBarLine)); + Assert.That(issues[0].Time, Is.EqualTo(1000.0)); + Assert.That(issues[1].Time, Is.EqualTo(2000.0)); + } + + private IBeatmap[] createBeatmapSetWithTimingPoints(params (double time, bool omitFirstBarLine)[][] timingData) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingData.Length]; + + for (int i = 0; i < timingData.Length; i++) + { + beatmaps[i] = createBeatmapWithTimingPoints(timingData[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + beatmaps[i].BeatmapInfo.Ruleset = new TaikoRuleset().RulesetInfo; + } + + // Configure the beatmapset to contain all the beatmap infos + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap createBeatmapWithTimingPoints((double time, bool omitFirstBarLine)[] timingData, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + } + }; + + foreach ((double time, bool omitFirstBarLine) in timingData) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = 500, // Standard BPM + OmitFirstBarLine = omitFirstBarLine + }); + } + + return beatmap; + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties) + { + return new BeatmapVerifierContext( + currentBeatmap, + new TestWorkingBeatmap(currentBeatmap), + DifficultyRating.ExpertPlus, + beatmapInfo => allDifficulties.FirstOrDefault(b => b.BeatmapInfo.Equals(beatmapInfo)) + ); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs new file mode 100644 index 0000000000..3d2e888230 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Taiko.Edit.Checks +{ + public class CheckTaikoInconsistentSkipBarLine : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Timing, "Inconsistent \"Skip Bar Line\" setting"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateInconsistentOmitFirstBarLine(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var difficulties = context.BeatmapsetDifficulties; + + if (difficulties.Count <= 1) + yield break; + + // Inconsistent bar line omission only matters for osu!taiko difficulties, so only check those + var taikoBeatmaps = difficulties.Where(b => b.BeatmapInfo.Ruleset.ShortName == "taiko").ToList(); + + if (taikoBeatmaps.Count <= 1) + yield break; + + var referenceBeatmap = context.Beatmap; + var referenceTimingPoints = referenceBeatmap.ControlPointInfo.TimingPoints; + + foreach (var beatmap in taikoBeatmaps) + { + if (beatmap == referenceBeatmap) + continue; + + var timingPoints = beatmap.ControlPointInfo.TimingPoints; + + foreach (var referencePoint in referenceTimingPoints) + { + var matchingPoint = TimingCheckUtils.FindExactMatchingTimingPoint(timingPoints, referencePoint.Time); + + if (matchingPoint == null) + // Inconsistent timing points - that's handled by `CheckInconsistentTimingControlPoints`, so skip + continue; + + if (referencePoint.OmitFirstBarLine != matchingPoint.OmitFirstBarLine) + { + yield return new IssueTemplateInconsistentOmitFirstBarLine(this).Create(referencePoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + } + } + } + + public class IssueTemplateInconsistentOmitFirstBarLine : IssueTemplate + { + public IssueTemplateInconsistentOmitFirstBarLine(ICheck check) + : base(check, IssueType.Problem, "Inconsistent \"Skip Bar Line\" setting in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs index 23d0abed08..737347a64c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs @@ -22,6 +22,9 @@ namespace osu.Game.Rulesets.Taiko.Edit // Settings new CheckTaikoAbnormalDifficultySettings(), + + // Timing + new CheckTaikoInconsistentSkipBarLine(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs new file mode 100644 index 0000000000..899a59a24f --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs @@ -0,0 +1,256 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckInconsistentTimingControlPointsTest + { + private CheckInconsistentTimingControlPoints check = null!; + + [SetUp] + public void Setup() + { + check = new CheckInconsistentTimingControlPoints(); + } + + [Test] + public void TestConsistentTiming() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0, 2000.0 }, // Timing at 1000ms and 2000ms + new[] { 1000.0, 2000.0 } // Same timing + ); + + var context = createContext(beatmaps[0], beatmaps); + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMissingTimingPoint() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0, 2000.0 }, // Reference has timing at 1000ms and 2000ms + new[] { 1000.0 } // Second difficulty missing timing at 2000ms + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateMissingTimingPoint)); + } + + [Test] + public void TestInconsistentBPM() + { + var beatmaps = createBeatmapSetWithBPM( + new[] { (1000.0, 500.0) }, // Reference: 120 BPM (500ms beat length) + new[] { (1000.0, 600.0) } // Second: 100 BPM (600ms beat length) + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateInconsistentBPM)); + } + + [Test] + public void TestInconsistentMeter() + { + var beatmaps = createBeatmapSetWithMeter( + new[] { (1000.0, TimeSignature.SimpleQuadruple) }, // Reference: 4/4 + new[] { (1000.0, TimeSignature.SimpleTriple) } // Second: 3/4 + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateInconsistentMeter)); + } + + [Test] + public void TestDecimalOffset() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0 }, // Reference at exactly 1000ms + new[] { 1000.5 } // Second at 1000.5ms (decimal difference) + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateMissingTimingPointMinor)); + } + + [Test] + public void TestSingleDifficulty() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0, 2000.0 } // Only one difficulty + ); + + var context = createContext(beatmaps[0], beatmaps); + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestExtraTimingPoint() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0 }, // Reference has timing at 1000ms + new[] { 1000.0, 2000.0 } // Second has additional timing at 2000ms + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateExtraTimingPoint)); + } + + private IBeatmap[] createBeatmapSetWithTiming(params double[][] timingPoints) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingPoints.Length]; + + for (int i = 0; i < timingPoints.Length; i++) + { + beatmaps[i] = createBeatmapWithTiming(timingPoints[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap[] createBeatmapSetWithBPM(params (double time, double beatLength)[][] timingData) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingData.Length]; + + for (int i = 0; i < timingData.Length; i++) + { + beatmaps[i] = createBeatmapWithBPM(timingData[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap[] createBeatmapSetWithMeter(params (double time, TimeSignature meter)[][] timingData) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingData.Length]; + + for (int i = 0; i < timingData.Length; i++) + { + beatmaps[i] = createBeatmapWithMeter(timingData[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap createBeatmapWithTiming(double[] timingPoints, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + }, + ControlPointInfo = new ControlPointInfo() + }; + + foreach (double time in timingPoints) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = 500 // 120 BPM + }); + } + + return beatmap; + } + + private IBeatmap createBeatmapWithBPM((double time, double beatLength)[] timingData, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + }, + ControlPointInfo = new ControlPointInfo() + }; + + foreach ((double time, double beatLength) in timingData) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = beatLength + }); + } + + return beatmap; + } + + private IBeatmap createBeatmapWithMeter((double time, TimeSignature meter)[] timingData, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + }, + ControlPointInfo = new ControlPointInfo() + }; + + foreach ((double time, var meter) in timingData) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = 500, // 120 BPM + TimeSignature = meter + }); + } + + return beatmap; + } + + private BeatmapVerifierContext createContext(IBeatmap currentBeatmap, IBeatmap[] allDifficulties) + { + return new BeatmapVerifierContext( + currentBeatmap, + new TestWorkingBeatmap(currentBeatmap), + DifficultyRating.ExpertPlus, + beatmapInfo => allDifficulties.FirstOrDefault(b => b.BeatmapInfo.Equals(beatmapInfo)) + ); + } + } +} diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index eeccdc8e8a..c8b163c678 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -209,6 +209,16 @@ namespace osu.Game.Localisation /// public static LocalisableString OpenDiscussionPage => new TranslatableString(getKey(@"open_discussion_page"), @"Open beatmap discussion page"); + /// + /// "Current difficulty" + /// + public static LocalisableString CheckCurrentDifficulty => new TranslatableString(getKey(@"check_current_difficulty"), @"Current difficulty"); + + /// + /// "Entire beatmap set" + /// + public static LocalisableString CheckEntireBeatmapSet => new TranslatableString(getKey(@"check_entire_beatmap_set"), @"Entire beatmap set"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 868835342a..c0fc400a53 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -42,6 +42,7 @@ namespace osu.Game.Rulesets.Edit // Timing new CheckPreviewTime(), + new CheckInconsistentTimingControlPoints(), // Events new CheckBreaks(), diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs index 005902a8a1..a7e54528d2 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckAudioInVideo : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Audio track in video files"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Audio track in video files", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs index 8c0c01d5da..32af1ceba4 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Edit.Checks // There not existing a version with a bitrate of 128 kbps or higher is extremely rare. private const int min_bitrate = 128; - public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Too high or low audio bitrate"); + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Too high or low audio bitrate", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs index 5008c13d9a..c1351d053b 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Edit.Checks private const int low_width = 960; private const int low_height = 540; - public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Too high or low background resolution"); + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Too high or low background resolution", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs index ee950248db..a78a16953e 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Edit.Checks private const int delay_threshold = 5; private const int delay_threshold_negligible = 1; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Delayed hit sounds."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Delayed hit sounds.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs index 9a921ba808..346b79c8af 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Edit.Checks protected abstract string TypeOfFile { get; } protected abstract string? GetFilename(IBeatmap beatmap); - public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}"); + public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs b/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs index 9b6a861358..30973cfa76 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckHitsoundsFormat : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for hitsound formats."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for hitsound formats.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs index cf74ca3ea3..e7f06556cc 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckInconsistentMetadata : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Inconsistent metadata"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Inconsistent metadata", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs new file mode 100644 index 0000000000..8ed802e618 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs @@ -0,0 +1,146 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckInconsistentTimingControlPoints : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Timing, "Inconsistent timing control points"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateMissingTimingPoint(this), + new IssueTemplateExtraTimingPoint(this), + new IssueTemplateMissingTimingPointMinor(this), + new IssueTemplateInconsistentMeter(this), + new IssueTemplateInconsistentBPM(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var difficulties = context.BeatmapsetDifficulties; + + if (difficulties.Count <= 1) + yield break; + + // Use the current difficulty as reference + var referenceBeatmap = context.Beatmap; + var referenceTimingPoints = referenceBeatmap.ControlPointInfo.TimingPoints; + + foreach (var beatmap in difficulties) + { + if (beatmap == referenceBeatmap) + continue; + + var timingPoints = beatmap.ControlPointInfo.TimingPoints; + + // Check each timing point in the reference against this difficulty + foreach (var referencePoint in referenceTimingPoints) + { + var matchingPoint = TimingCheckUtils.FindMatchingTimingPoint(timingPoints, referencePoint.Time); + var exactMatchingPoint = TimingCheckUtils.FindExactMatchingTimingPoint(timingPoints, referencePoint.Time); + + if (matchingPoint == null) + { + yield return new IssueTemplateMissingTimingPoint(this).Create(referencePoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + else + { + // Check for meter signature inconsistency + if (!referencePoint.TimeSignature.Equals(matchingPoint.TimeSignature)) + { + yield return new IssueTemplateInconsistentMeter(this).Create(referencePoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + + // Check for BPM inconsistency + if (Math.Abs(referencePoint.BeatLength - matchingPoint.BeatLength) > TimingCheckUtils.TIME_OFFSET_TOLERANCE_MS) + { + yield return new IssueTemplateInconsistentBPM(this).Create(referencePoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + + // Check for exact timing match (decimal precision) + if (exactMatchingPoint == null) + { + yield return new IssueTemplateMissingTimingPointMinor(this).Create(referencePoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + } + } + + // Check timing points in this difficulty that aren't in the reference + foreach (var timingPoint in timingPoints) + { + var matchingReferencePoint = TimingCheckUtils.FindMatchingTimingPoint(referenceTimingPoints, timingPoint.Time); + var exactMatchingReferencePoint = TimingCheckUtils.FindExactMatchingTimingPoint(referenceTimingPoints, timingPoint.Time); + + if (matchingReferencePoint == null) + { + yield return new IssueTemplateExtraTimingPoint(this).Create(timingPoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + else if (exactMatchingReferencePoint == null) + { + yield return new IssueTemplateMissingTimingPointMinor(this).Create(timingPoint.Time, beatmap.BeatmapInfo.DifficultyName); + } + } + } + } + + public class IssueTemplateMissingTimingPoint : IssueTemplate + { + public IssueTemplateMissingTimingPoint(ICheck check) + : base(check, IssueType.Problem, "Missing timing control point in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateExtraTimingPoint : IssueTemplate + { + public IssueTemplateExtraTimingPoint(ICheck check) + : base(check, IssueType.Problem, "Extra timing control point in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateMissingTimingPointMinor : IssueTemplate + { + public IssueTemplateMissingTimingPointMinor(ICheck check) + : base(check, IssueType.Negligible, "Timing control point has decimally different offset in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateInconsistentMeter : IssueTemplate + { + public IssueTemplateInconsistentMeter(ICheck check) + : base(check, IssueType.Problem, "Inconsistent time signature in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateInconsistentBPM : IssueTemplate + { + public IssueTemplateInconsistentBPM(ICheck check) + : base(check, IssueType.Problem, "Inconsistent BPM in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs b/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs index 1a31d19a78..5871cf51ff 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckSongFormat : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for song formats."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for song formats.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs b/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs index 9c702ad58a..742054777e 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckTitleMarkers : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Checks for incorrect formats of (TV Size) / (Game Ver.) / (Short Ver.) / (Cut Ver.) / (Sped Up Ver.) / etc in title."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Checks for incorrect formats of (TV Size) / (Game Ver.) / (Short Ver.) / (Cut Ver.) / (Sped Up Ver.) / etc in title.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs index 3f85926e04..7991797ddd 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Edit.Checks { private const int ms_threshold = 25; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs b/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs index c050932aa6..344dddec3e 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Edit.Checks private const int max_video_height = 720; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Resources, "Too high video resolution."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Resources, "Too high video resolution.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs index 75cb08002f..7048e944dd 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckZeroByteFiles : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Files, "Zero-byte files"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Files, "Zero-byte files", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { diff --git a/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs b/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs index cebb2f5455..cbc07f1fa3 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs @@ -15,10 +15,16 @@ namespace osu.Game.Rulesets.Edit.Checks.Components /// public readonly string Description; - public CheckMetadata(CheckCategory category, string description) + /// + /// Specifies whether this check is difficulty-specific or applies to the entire beatmapset. Set to by default. + /// + public readonly CheckScope Scope; + + public CheckMetadata(CheckCategory category, string description, CheckScope scope = CheckScope.Difficulty) { Category = category; Description = description; + Scope = scope; } } } diff --git a/osu.Game/Rulesets/Edit/Checks/Components/CheckScope.cs b/osu.Game/Rulesets/Edit/Checks/Components/CheckScope.cs new file mode 100644 index 0000000000..7dcc4d87f2 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/CheckScope.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Localisation; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public enum CheckScope + { + /// + /// Run checks that apply to the current difficulty. + /// + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.CheckCurrentDifficulty))] + Difficulty, + + /// + /// Run checks that apply to the beatmap set as a whole. + /// + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.CheckEntireBeatmapSet))] + BeatmapSet, + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs new file mode 100644 index 0000000000..f56f27813d --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public static class TimingCheckUtils + { + // Tolerance for exact time offset matching (in milliseconds) + public const double TIME_OFFSET_TOLERANCE_MS = 0.01; + + /// + /// Finds a timing control point that starts at approximately the same time (within 1ms after rounding). + /// + /// The collection of timing points to search. + /// The time to match against. + /// The matching timing control point, or null if none found. + public static TimingControlPoint? FindMatchingTimingPoint(IEnumerable timingPoints, double time) + { + return timingPoints.FirstOrDefault(tp => (int)tp.Time == (int)time); + } + + /// + /// Finds a timing control point that starts at precisely the same time (within timing tolerance). + /// + /// The collection of timing points to search. + /// The time to match against. + /// The exact matching timing control point, or null if none found. + public static TimingControlPoint? FindExactMatchingTimingPoint(IEnumerable timingPoints, double time) + { + return timingPoints.FirstOrDefault(tp => Precision.AlmostEquals(tp.Time, time, TIME_OFFSET_TOLERANCE_MS)); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index e2eeff9ad5..415a46c583 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -94,6 +94,7 @@ namespace osu.Game.Screens.Edit.Verify base.LoadComplete(); verify.InterpretedDifficulty.BindValueChanged(_ => Refresh()); + verify.VerifyChecksScope.BindValueChanged(_ => Refresh()); verify.HiddenIssueTypes.BindCollectionChanged((_, _) => Refresh()); Refresh(); @@ -116,7 +117,9 @@ namespace osu.Game.Screens.Edit.Verify private IEnumerable filter(IEnumerable issues) { - return issues.Where(issue => !verify.HiddenIssueTypes.Contains(issue.Template.Type)); + return issues.Where(issue => + !verify.HiddenIssueTypes.Contains(issue.Template.Type) && + issue.Check.Metadata.Scope == verify.VerifyChecksScope.Value); } } } diff --git a/osu.Game/Screens/Edit/Verify/IssueSettings.cs b/osu.Game/Screens/Edit/Verify/IssueSettings.cs index 6d3c0520a2..01b41e622a 100644 --- a/osu.Game/Screens/Edit/Verify/IssueSettings.cs +++ b/osu.Game/Screens/Edit/Verify/IssueSettings.cs @@ -10,6 +10,7 @@ namespace osu.Game.Screens.Edit.Verify { protected override IReadOnlyList CreateSections() => new Drawable[] { + new ScopeSection(), new InterpretationSection(), new VisibilitySection() }; diff --git a/osu.Game/Screens/Edit/Verify/ScopeSection.cs b/osu.Game/Screens/Edit/Verify/ScopeSection.cs new file mode 100644 index 0000000000..51807d5a8f --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/ScopeSection.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Screens.Edit.Verify +{ + internal partial class ScopeSection : EditorRoundedScreenSettingsSection + { + protected override string HeaderText => "Scope"; + + [BackgroundDependencyLoader] + private void load(VerifyScreen verify) + { + Flow.Add(new SettingsEnumDropdown + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + TooltipText = "Select which type of checks to display", + Current = verify.VerifyChecksScope.GetBoundCopy() + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs index fe508860e0..208b33770f 100644 --- a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs +++ b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs @@ -19,6 +19,8 @@ namespace osu.Game.Screens.Edit.Verify public readonly Bindable InterpretedDifficulty = new Bindable(); + public readonly Bindable VerifyChecksScope = new Bindable(); + public readonly BindableList HiddenIssueTypes = new BindableList { IssueType.Negligible }; public IssueList IssueList { get; private set; } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index aa053bb727..cba1d36ba7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -30,8 +30,8 @@ namespace osu.Game.Screens.SelectV2 /// public IDictionary> GroupItems => groupMap; - private readonly Dictionary> setMap = new Dictionary>(); - private readonly Dictionary> groupMap = new Dictionary>(); + private Dictionary> setMap = new Dictionary>(); + private Dictionary> groupMap = new Dictionary>(); private readonly Func getCriteria; private readonly Func>? getCollections; @@ -46,8 +46,9 @@ namespace osu.Game.Screens.SelectV2 { return await Task.Run(() => { - setMap.Clear(); - groupMap.Clear(); + // preallocate space for the new mappings using last known estimates + var newSetMap = new Dictionary>(setMap.Count); + var newGroupMap = new Dictionary>(groupMap.Count); var criteria = getCriteria(); var newItems = new List(); @@ -67,7 +68,7 @@ namespace osu.Game.Screens.SelectV2 if (group != null) { - groupMap[group] = currentGroupItems = new HashSet(); + newGroupMap[group] = currentGroupItems = new HashSet(); addItem(groupItem = new CarouselItem(group) { @@ -78,14 +79,16 @@ namespace osu.Game.Screens.SelectV2 foreach (var item in itemsInGroup) { + cancellationToken.ThrowIfCancellationRequested(); + var beatmap = (BeatmapInfo)item.Model; bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID; if (newBeatmapSet) { - if (!setMap.TryGetValue(beatmap.BeatmapSet!, out currentSetItems)) - setMap[beatmap.BeatmapSet!] = currentSetItems = new HashSet(); + if (!newSetMap.TryGetValue(beatmap.BeatmapSet!, out currentSetItems)) + newSetMap[beatmap.BeatmapSet!] = currentSetItems = new HashSet(); } if (BeatmapSetsGroupedTogether) @@ -125,6 +128,10 @@ namespace osu.Game.Screens.SelectV2 } } + cancellationToken.ThrowIfCancellationRequested(); + + Interlocked.Exchange(ref setMap, newSetMap); + Interlocked.Exchange(ref groupMap, newGroupMap); return newItems; }, cancellationToken).ConfigureAwait(false); }