From 9cfba9008fc726bb785b39f29608ab3492d130a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 14 Apr 2025 13:13:21 +0200 Subject: [PATCH 01/33] Add extra comments regarding notation --- osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs | 4 ++++ osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs | 4 ++++ osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs index 497d8a18b8..a83b61360b 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneReplayStability.cs @@ -17,6 +17,10 @@ namespace osu.Game.Rulesets.Mania.Tests { private static readonly object[][] test_cases = { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + // OD = 5 test cases. // PERFECT hit window is [ -19.4ms, 19.4ms] // GREAT hit window is [ -49.0ms, 49.0ms] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs index aca8f757f2..2303b17d96 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs @@ -18,6 +18,10 @@ namespace osu.Game.Rulesets.Osu.Tests { private static readonly object[][] test_cases = { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + // OD = 5 test cases. // GREAT hit window is [ -50ms, 50ms] // OK hit window is [-100ms, 100ms] diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs index 4a2cd024b0..62bbebcf0b 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs @@ -17,6 +17,10 @@ namespace osu.Game.Rulesets.Taiko.Tests { private static readonly object[][] test_cases = { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + // OD = 5 test cases. // GREAT hit window is [-35ms, 35ms] // OK hit window is [-80ms, 80ms] From 82b2a92894a796b365404dd42bb78ca38b4bf356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 14 Apr 2025 13:47:42 +0200 Subject: [PATCH 02/33] Add test cases covering correct legacy replay playback with respect to hitwindow treatment This continues on https://github.com/ppy/osu/pull/32770 via adding test cases which cover treatment of hit windows in stable in osu!, taiko, and mania. The test cases are exportable to beatmap `.osu` files and replay `.osr` files for stable crosscheck by setting `ExportLocation` on the test scene classes to a non-null path. For the most part, osu! and taiko ground truth matches previous findings - hit windows in those rulesets are floored to the nearest integer. The real "star" of this diff is mania, because: - The hit windows in mania depend on: - overall difficulty (as expected) - whether Score V2 is active - if Score V2 is not active, the hit windows also depend on whether the map was converted from another ruleset or not - Regardless of all aforementioned factors, mania hitwindows are *not symmetrical*. Due to what *appears* to be a straight-up bug, it is *not possible to achieve a MEH / 50 hit result when hitting late*. There is specific code that coerces late hits beyond 100 hit window range to full misses: https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameplayElements/HitObjectManagerMania.cs#L737-L751 Note that despite the fact that I'm PRing these test cases, none of this is a promise that all of stable behaviours will be returning unchanged when I PR something to actually do something about this and the other issue of replay instability. This is just coverage, to be used for awareness of what's still broken. The extent of how much stable is going to be humored here going forward will be subject to negotiation. --- .../TestSceneLegacyReplayPlayback.cs | 470 ++++++++++++++++++ .../TestSceneLegacyReplayPlayback.cs | 118 +++++ .../TestSceneLegacyReplayPlayback.cs | 102 ++++ .../Visual/LegacyReplayPlaybackTestScene.cs | 157 ++++++ 4 files changed, 847 insertions(+) create mode 100644 osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs create mode 100644 osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs create mode 100644 osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs create mode 100644 osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs new file mode 100644 index 0000000000..acd97b92a9 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs @@ -0,0 +1,470 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] + public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene + { + protected override Ruleset CreateRuleset() => new ManiaRuleset(); + + protected override string? ExportLocation => null; + + private static readonly object[][] score_v2_test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + + // Note that mania hitwindows are heavily idiosyncratic, + // and if you *think* a number here is wrong, probably double check. + + // Known issues / complexities: + // - There is a disparate set of hitwindow ranges for: score V1 non-converts, score V1 converts, and score V2 (regardless of convert) + // - It is NEVER POSSIBLE to get a MEH result when late; exceeding the OK hit windows will result in a MISS. + // Additionally, the OK hit window when late is EXCLUSIVE / OPEN rather than INCLUSIVE / CLOSED. + // Relevant stable source: https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameplayElements/HitObjectManagerMania.cs#L737-L751 + // - There is also a seemingly mania-specific issue wherein key inputs registered before time instant 0 get truncated to time 0, + // which is why the beatmaps used below make sure not to cross that boundary (the note starts at t=300ms). + // This is not an issue in osu! or taiko. + // The source of this behaviour has not been investigated in detail. + + // OD = 5 test cases. + // PERFECT hit window is [ -19ms, 19ms] + // GREAT hit window is [ -49ms, 49ms] + // GOOD hit window is [ -82ms, 82ms] + // OK hit window is [-112ms, 112ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-136ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -18d, HitResult.Perfect }, + new object[] { 5f, -19d, HitResult.Perfect }, + new object[] { 5f, -20d, HitResult.Great }, + new object[] { 5f, -21d, HitResult.Great }, + new object[] { 5f, -48d, HitResult.Great }, + new object[] { 5f, -49d, HitResult.Great }, + new object[] { 5f, -50d, HitResult.Good }, + new object[] { 5f, -51d, HitResult.Good }, + new object[] { 5f, -81d, HitResult.Good }, + new object[] { 5f, -82d, HitResult.Good }, + new object[] { 5f, -83d, HitResult.Ok }, + new object[] { 5f, -84d, HitResult.Ok }, + new object[] { 5f, -111d, HitResult.Ok }, + new object[] { 5f, -112d, HitResult.Ok }, + new object[] { 5f, -113d, HitResult.Meh }, + new object[] { 5f, -114d, HitResult.Meh }, + new object[] { 5f, -135d, HitResult.Meh }, + new object[] { 5f, -136d, HitResult.Meh }, + new object[] { 5f, -137d, HitResult.Miss }, + new object[] { 5f, -138d, HitResult.Miss }, + new object[] { 5f, 111d, HitResult.Ok }, + new object[] { 5f, 112d, HitResult.Miss }, + new object[] { 5f, 113d, HitResult.Miss }, + new object[] { 5f, 114d, HitResult.Miss }, + new object[] { 5f, 135d, HitResult.Miss }, + new object[] { 5f, 136d, HitResult.Miss }, + new object[] { 5f, 137d, HitResult.Miss }, + new object[] { 5f, 138d, HitResult.Miss }, + + // OD = 9.3 test cases. + // PERFECT hit window is [ -14ms, 14ms] + // GREAT hit window is [ -36ms, 36ms] + // GOOD hit window is [ -69ms, 69ms] + // OK hit window is [ -99ms, 99ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-123ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 9.3f, 13d, HitResult.Perfect }, + new object[] { 9.3f, 14d, HitResult.Perfect }, + new object[] { 9.3f, 15d, HitResult.Great }, + new object[] { 9.3f, 16d, HitResult.Great }, + new object[] { 9.3f, 35d, HitResult.Great }, + new object[] { 9.3f, 36d, HitResult.Great }, + new object[] { 9.3f, 37d, HitResult.Good }, + new object[] { 9.3f, 38d, HitResult.Good }, + new object[] { 9.3f, 68d, HitResult.Good }, + new object[] { 9.3f, 69d, HitResult.Good }, + new object[] { 9.3f, 70d, HitResult.Ok }, + new object[] { 9.3f, 71d, HitResult.Ok }, + new object[] { 9.3f, 98d, HitResult.Ok }, + new object[] { 9.3f, 99d, HitResult.Miss }, + new object[] { 9.3f, 100d, HitResult.Miss }, + new object[] { 9.3f, 101d, HitResult.Miss }, + new object[] { 9.3f, 122d, HitResult.Miss }, + new object[] { 9.3f, 123d, HitResult.Miss }, + new object[] { 9.3f, 124d, HitResult.Miss }, + new object[] { 9.3f, 125d, HitResult.Miss }, + new object[] { 9.3f, -98d, HitResult.Ok }, + new object[] { 9.3f, -99d, HitResult.Ok }, + new object[] { 9.3f, -100d, HitResult.Meh }, + new object[] { 9.3f, -101d, HitResult.Meh }, + new object[] { 9.3f, -122d, HitResult.Meh }, + new object[] { 9.3f, -123d, HitResult.Meh }, + new object[] { 9.3f, -124d, HitResult.Miss }, + new object[] { 9.3f, -125d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_test_cases = + { + // OD = 5 test cases. + // PERFECT hit window is [ -16ms, 16ms] + // GREAT hit window is [ -49ms, 49ms] + // GOOD hit window is [ -82ms, 82ms] + // OK hit window is [-112ms, 112ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-136ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -15d, HitResult.Perfect }, + new object[] { 5f, -16d, HitResult.Perfect }, + new object[] { 5f, -17d, HitResult.Great }, + new object[] { 5f, -18d, HitResult.Great }, + new object[] { 5f, -48d, HitResult.Great }, + new object[] { 5f, -49d, HitResult.Great }, + new object[] { 5f, -50d, HitResult.Good }, + new object[] { 5f, -51d, HitResult.Good }, + new object[] { 5f, -81d, HitResult.Good }, + new object[] { 5f, -82d, HitResult.Good }, + new object[] { 5f, -83d, HitResult.Ok }, + new object[] { 5f, -84d, HitResult.Ok }, + new object[] { 5f, -111d, HitResult.Ok }, + new object[] { 5f, -112d, HitResult.Ok }, + new object[] { 5f, -113d, HitResult.Meh }, + new object[] { 5f, -114d, HitResult.Meh }, + new object[] { 5f, -135d, HitResult.Meh }, + new object[] { 5f, -136d, HitResult.Meh }, + new object[] { 5f, -137d, HitResult.Miss }, + new object[] { 5f, -138d, HitResult.Miss }, + new object[] { 5f, 111d, HitResult.Ok }, + new object[] { 5f, 112d, HitResult.Miss }, + new object[] { 5f, 113d, HitResult.Miss }, + new object[] { 5f, 114d, HitResult.Miss }, + new object[] { 5f, 135d, HitResult.Miss }, + new object[] { 5f, 136d, HitResult.Miss }, + new object[] { 5f, 137d, HitResult.Miss }, + new object[] { 5f, 138d, HitResult.Miss }, + + // OD = 9.3 test cases. + // PERFECT hit window is [ -16ms, 16ms] + // GREAT hit window is [ -36ms, 36ms] + // GOOD hit window is [ -69ms, 69ms] + // OK hit window is [ -99ms, 99ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-123ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 9.3f, 15d, HitResult.Perfect }, + new object[] { 9.3f, 16d, HitResult.Perfect }, + new object[] { 9.3f, 17d, HitResult.Great }, + new object[] { 9.3f, 18d, HitResult.Great }, + new object[] { 9.3f, 35d, HitResult.Great }, + new object[] { 9.3f, 36d, HitResult.Great }, + new object[] { 9.3f, 37d, HitResult.Good }, + new object[] { 9.3f, 38d, HitResult.Good }, + new object[] { 9.3f, 68d, HitResult.Good }, + new object[] { 9.3f, 69d, HitResult.Good }, + new object[] { 9.3f, 70d, HitResult.Ok }, + new object[] { 9.3f, 71d, HitResult.Ok }, + new object[] { 9.3f, 98d, HitResult.Ok }, + new object[] { 9.3f, 99d, HitResult.Miss }, + new object[] { 9.3f, 100d, HitResult.Miss }, + new object[] { 9.3f, 101d, HitResult.Miss }, + new object[] { 9.3f, 122d, HitResult.Miss }, + new object[] { 9.3f, 123d, HitResult.Miss }, + new object[] { 9.3f, 124d, HitResult.Miss }, + new object[] { 9.3f, 125d, HitResult.Miss }, + new object[] { 9.3f, -98d, HitResult.Ok }, + new object[] { 9.3f, -99d, HitResult.Ok }, + new object[] { 9.3f, -100d, HitResult.Meh }, + new object[] { 9.3f, -101d, HitResult.Meh }, + new object[] { 9.3f, -122d, HitResult.Meh }, + new object[] { 9.3f, -123d, HitResult.Meh }, + new object[] { 9.3f, -124d, HitResult.Miss }, + new object[] { 9.3f, -125d, HitResult.Miss }, + + // OD = 3.1 test cases. + // PERFECT hit window is [ -16ms, 16ms] + // GREAT hit window is [ -54ms, 54ms] + // GOOD hit window is [ -87ms, 87ms] + // OK hit window is [-117ms, 117ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-141ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 3.1f, 15d, HitResult.Perfect }, + new object[] { 3.1f, 16d, HitResult.Perfect }, + new object[] { 3.1f, 17d, HitResult.Great }, + new object[] { 3.1f, 18d, HitResult.Great }, + new object[] { 3.1f, 53d, HitResult.Great }, + new object[] { 3.1f, 54d, HitResult.Great }, + new object[] { 3.1f, 55d, HitResult.Good }, + new object[] { 3.1f, 56d, HitResult.Good }, + new object[] { 3.1f, 86d, HitResult.Good }, + new object[] { 3.1f, 87d, HitResult.Good }, + new object[] { 3.1f, 88d, HitResult.Ok }, + new object[] { 3.1f, 89d, HitResult.Ok }, + new object[] { 3.1f, 116d, HitResult.Ok }, + new object[] { 3.1f, 117d, HitResult.Miss }, + new object[] { 3.1f, 118d, HitResult.Miss }, + new object[] { 3.1f, 119d, HitResult.Miss }, + new object[] { 3.1f, 140d, HitResult.Miss }, + new object[] { 3.1f, 141d, HitResult.Miss }, + new object[] { 3.1f, 142d, HitResult.Miss }, + new object[] { 3.1f, 143d, HitResult.Miss }, + new object[] { 3.1f, -116d, HitResult.Ok }, + new object[] { 3.1f, -117d, HitResult.Ok }, + new object[] { 3.1f, -118d, HitResult.Meh }, + new object[] { 3.1f, -119d, HitResult.Meh }, + new object[] { 3.1f, -140d, HitResult.Meh }, + new object[] { 3.1f, -141d, HitResult.Meh }, + new object[] { 3.1f, -142d, HitResult.Miss }, + new object[] { 3.1f, -143d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_convert_test_cases = + { + // OD = 5 test cases. + // PERFECT hit window is [ -16ms, 16ms] + // GREAT hit window is [ -34ms, 34ms] + // GOOD hit window is [ -67ms, 67ms] + // OK hit window is [ -97ms, 97ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-121ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -15d, HitResult.Perfect }, + new object[] { 5f, -16d, HitResult.Perfect }, + new object[] { 5f, -17d, HitResult.Great }, + new object[] { 5f, -18d, HitResult.Great }, + new object[] { 5f, -33d, HitResult.Great }, + new object[] { 5f, -34d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Good }, + new object[] { 5f, -36d, HitResult.Good }, + new object[] { 5f, -66d, HitResult.Good }, + new object[] { 5f, -67d, HitResult.Good }, + new object[] { 5f, -68d, HitResult.Ok }, + new object[] { 5f, -69d, HitResult.Ok }, + new object[] { 5f, -96d, HitResult.Ok }, + new object[] { 5f, -97d, HitResult.Ok }, + new object[] { 5f, -98d, HitResult.Meh }, + new object[] { 5f, -99d, HitResult.Meh }, + new object[] { 5f, -120d, HitResult.Meh }, + new object[] { 5f, -121d, HitResult.Meh }, + new object[] { 5f, -122d, HitResult.Miss }, + new object[] { 5f, -123d, HitResult.Miss }, + new object[] { 5f, 96d, HitResult.Ok }, + new object[] { 5f, 97d, HitResult.Miss }, + new object[] { 5f, 98d, HitResult.Miss }, + new object[] { 5f, 99d, HitResult.Miss }, + new object[] { 5f, 120d, HitResult.Miss }, + new object[] { 5f, 121d, HitResult.Miss }, + new object[] { 5f, 122d, HitResult.Miss }, + new object[] { 5f, 123d, HitResult.Miss }, + + // OD = 3.1 test cases. + // PERFECT hit window is [ -16ms, 16ms] + // GREAT hit window is [ -47ms, 47ms] + // GOOD hit window is [ -77ms, 77ms] + // OK hit window is [ -97ms, 97ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-121ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 3.1f, 15d, HitResult.Perfect }, + new object[] { 3.1f, 16d, HitResult.Perfect }, + new object[] { 3.1f, 17d, HitResult.Great }, + new object[] { 3.1f, 18d, HitResult.Great }, + new object[] { 3.1f, 46d, HitResult.Great }, + new object[] { 3.1f, 47d, HitResult.Great }, + new object[] { 3.1f, 48d, HitResult.Good }, + new object[] { 3.1f, 49d, HitResult.Good }, + new object[] { 3.1f, 76d, HitResult.Good }, + new object[] { 3.1f, 77d, HitResult.Good }, + new object[] { 3.1f, 78d, HitResult.Ok }, + new object[] { 3.1f, 79d, HitResult.Ok }, + new object[] { 3.1f, 96d, HitResult.Ok }, + new object[] { 3.1f, 97d, HitResult.Miss }, + new object[] { 3.1f, 98d, HitResult.Miss }, + new object[] { 3.1f, 99d, HitResult.Miss }, + new object[] { 3.1f, 120d, HitResult.Miss }, + new object[] { 3.1f, 121d, HitResult.Miss }, + new object[] { 3.1f, 122d, HitResult.Miss }, + new object[] { 3.1f, 123d, HitResult.Miss }, + new object[] { 3.1f, -96d, HitResult.Ok }, + new object[] { 3.1f, -97d, HitResult.Ok }, + new object[] { 3.1f, -98d, HitResult.Meh }, + new object[] { 3.1f, -99d, HitResult.Meh }, + new object[] { 3.1f, -120d, HitResult.Meh }, + new object[] { 3.1f, -121d, HitResult.Meh }, + new object[] { 3.1f, -122d, HitResult.Miss }, + new object[] { 3.1f, -123d, HitResult.Miss }, + }; + + [TestCaseSource(nameof(score_v2_test_cases))] + public void TestHitWindowTreatmentWithScoreV2(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double note_time = 300; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)) + { + HitObjects = + { + new Note + { + StartTime = note_time, + Column = 0, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + CircleSize = 1, + }, + BeatmapInfo = + { + Ruleset = new ManiaRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset()!.RulesetInfo, + Mods = [new ModScoreV2()] + } + }; + + RunTest($@"SV2 single note @ OD{overallDifficulty}", beatmap, $@"SV2 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_test_cases))] + public void TestHitWindowTreatmentWithScoreV1NonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double note_time = 300; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)) + { + HitObjects = + { + new Note + { + StartTime = note_time, + Column = 0, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + CircleSize = 1, + }, + BeatmapInfo = + { + Ruleset = new ManiaRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset()!.RulesetInfo, + } + }; + + RunTest($@"SV1 single note @ OD{overallDifficulty}", beatmap, $@"SV1 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_convert_test_cases))] + public void TestHitWindowTreatmentWithScoreV1Convert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double note_time = 300; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new Beatmap + { + HitObjects = + { + new FakeCircle + { + StartTime = note_time, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + }, + BeatmapInfo = + { + Ruleset = new RulesetInfo { OnlineID = 0 } + }, + ControlPointInfo = cpi, + }; + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset()!.RulesetInfo, + Mods = [new ManiaModKey1()], + } + }; + + RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private class FakeCircle : HitObject, IHasPosition + { + public float X + { + get => Position.X; + set => Position = new Vector2(value, Position.Y); + } + + public float Y + { + get => Position.Y; + set => Position = new Vector2(Position.X, value); + } + + public Vector2 Position { get; set; } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs new file mode 100644 index 0000000000..5a085fe17c --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] + public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene + { + protected override Ruleset CreateRuleset() => new OsuRuleset(); + + protected override string? ExportLocation => null; + + private static readonly object[][] test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + // Additionally, note that offsets provided in double will be rounded to the nearest integer. + + // OD = 5 test cases. + // GREAT hit window is ( -50ms, 50ms) + // OK hit window is (-100ms, 100ms) + // MEH hit window is (-150ms, 150ms) + new object[] { 5f, 48d, HitResult.Great }, + new object[] { 5f, 49d, HitResult.Great }, + new object[] { 5f, 50d, HitResult.Ok }, + new object[] { 5f, 51d, HitResult.Ok }, + new object[] { 5f, 98d, HitResult.Ok }, + new object[] { 5f, 99d, HitResult.Ok }, + new object[] { 5f, 100d, HitResult.Meh }, + new object[] { 5f, 101d, HitResult.Meh }, + new object[] { 5f, 148d, HitResult.Meh }, + new object[] { 5f, 149d, HitResult.Meh }, + new object[] { 5f, 150d, HitResult.Miss }, + new object[] { 5f, 151d, HitResult.Miss }, + + // OD = 5.7 test cases. + // GREAT hit window is ( -45ms, 45ms) + // OK hit window is ( -94ms, 94ms) + // MEH hit window is (-143ms, 143ms) + new object[] { 5.7f, 43d, HitResult.Great }, + new object[] { 5.7f, 44d, HitResult.Great }, + new object[] { 5.7f, 45d, HitResult.Ok }, + new object[] { 5.7f, 46d, HitResult.Ok }, + new object[] { 5.7f, 92d, HitResult.Ok }, + new object[] { 5.7f, 93d, HitResult.Ok }, + new object[] { 5.7f, 94d, HitResult.Meh }, + new object[] { 5.7f, 95d, HitResult.Meh }, + new object[] { 5.7f, 141d, HitResult.Meh }, + new object[] { 5.7f, 142d, HitResult.Meh }, + new object[] { 5.7f, 143d, HitResult.Miss }, + new object[] { 5.7f, 144d, HitResult.Miss }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double hit_circle_time = 100; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + StartTime = hit_circle_time, + Position = OsuPlayfield.BASE_SIZE / 2 + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset()!.RulesetInfo, + } + }; + + RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs new file mode 100644 index 0000000000..4703b38e57 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")] + public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene + { + protected override string? ExportLocation => null; + + protected override Ruleset? CreateRuleset() => new TaikoRuleset(); + + private static readonly object[][] test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + + // OD = 5 test cases. + // GREAT hit window is (-35ms, 35ms) + // OK hit window is (-80ms, 80ms) + new object[] { 5f, -33d, HitResult.Great }, + new object[] { 5f, -34d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Ok }, + new object[] { 5f, -36d, HitResult.Ok }, + new object[] { 5f, -78d, HitResult.Ok }, + new object[] { 5f, -79d, HitResult.Ok }, + new object[] { 5f, -80d, HitResult.Miss }, + new object[] { 5f, -81d, HitResult.Miss }, + + // OD = 7.8 test cases. + // GREAT hit window is (-26ms, 26ms) + // OK hit window is (-63ms, 63ms) + new object[] { 7.8f, -24d, HitResult.Great }, + new object[] { 7.8f, -25d, HitResult.Great }, + new object[] { 7.8f, -26d, HitResult.Ok }, + new object[] { 7.8f, -27d, HitResult.Ok }, + new object[] { 7.8f, -61d, HitResult.Ok }, + new object[] { 7.8f, -62d, HitResult.Ok }, + new object[] { 7.8f, -63d, HitResult.Miss }, + new object[] { 7.8f, -64d, HitResult.Miss }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double hit_time = 100; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit + { + StartTime = hit_time, + Type = HitType.Centre, + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new TaikoRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset()!.RulesetInfo, + } + }; + + RunTest($@"single hit @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + } +} diff --git a/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs b/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs new file mode 100644 index 0000000000..5f973d1e4e --- /dev/null +++ b/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs @@ -0,0 +1,157 @@ +// 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.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual +{ + /// + /// The goal of this abstract test class is to exercise correct playback of replays sourced from previous osu! versions. + /// Use to exercise that property. + /// + [HeadlessTest] + [TestFixture] + public abstract partial class LegacyReplayPlaybackTestScene : RateAdjustedBeatmapTestScene + { + private ReplayPlayer currentPlayer = null!; + private readonly List results = new List(); + + /// + /// This is provided as a convenience for testing behaviour against osu!stable. + /// Setting this field to a non-null path will cause beatmap files and replays used in all test cases + /// to be exported to disk so that they can be cross-checked against stable. + /// + protected abstract string? ExportLocation { get; } + + /// + /// Encodes the supplied , decodes the result of encoding, runs the result of decoding against the supplied , + /// and checks that the judgement results recorded still match . + /// If is set, exports both the beatmap and the replay to said location. + /// + protected void RunTest(string beatmapName, IBeatmap beatmap, string replayName, Score originalScore, IEnumerable expectedResults) + { + IBeatmap playableBeatmap = null!; + MemoryStream beatmapStream = new MemoryStream(); + MemoryStream scoreStream = new MemoryStream(); + Score decodedScore = null!; + + AddStep(@"set up beatmap", () => + { + beatmap.Metadata.Title = beatmapName; + Beatmap.Value = CreateWorkingBeatmap(beatmap); + Ruleset.Value = CreateRuleset()!.RulesetInfo; + playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + + var beatmapEncoder = new LegacyBeatmapEncoder(beatmap, null); + + using (var writer = new StreamWriter(beatmapStream, Encoding.UTF8, leaveOpen: true)) + beatmapEncoder.Encode(writer); + + beatmapStream.Seek(0, SeekOrigin.Begin); + playableBeatmap.BeatmapInfo.MD5Hash = beatmapStream.ComputeMD5Hash(); + }); + + AddStep(@"encode score", () => + { + originalScore.ScoreInfo.BeatmapInfo = playableBeatmap.BeatmapInfo; + var encoder = new LegacyScoreEncoder(originalScore, playableBeatmap); + encoder.Encode(scoreStream, leaveOpen: true); + + // `LegacyScoreEncoder` hardcodes a replay version that belongs to lazer. + // here we want to simulate a stable replay, which should have the classic mod attached etc. + // to that end, we do a post-encode step to specify a stable-like replay version. + scoreStream.Position = 1; + + using (var sw = new SerializationWriter(scoreStream, leaveOpen: true)) + { + const int version = 20250414; + sw.Write(version); + } + + scoreStream.Position = 0; + }); + + if (ExportLocation != null) + { + AddStep("export beatmap", () => + { + using var stream = File.Open(Path.Combine(ExportLocation, $"{beatmapName}.osu"), FileMode.Create); + beatmapStream.CopyTo(stream); + beatmapStream.Position = 0; + }); + + AddStep("export score", () => + { + using var stream = File.Open(Path.Combine(ExportLocation, $@"{replayName}.osr"), FileMode.Create); + scoreStream.CopyTo(stream); + scoreStream.Position = 0; + }); + } + + AddStep(@"decode score", () => + { + using (scoreStream) + { + scoreStream.Position = 0; + decodedScore = new TestScoreDecoder(Beatmap.Value, Ruleset.Value).Parse(scoreStream); + } + }); + + AddAssert(@"classic mod present", () => decodedScore.ScoreInfo.Mods.Any(mod => mod is ModClassic)); + AddStep(@"push player", () => pushNewPlayer(decodedScore)); + + AddUntilStep(@"Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddAssert(@"classic mod present", () => currentPlayer.GameplayState.Mods.Any(mod => mod is ModClassic)); + AddUntilStep(@"Wait for completion", () => currentPlayer.GameplayState.HasCompleted); + AddAssert(@"judgement results after encode are correct", () => results.Select(r => r.Type), () => Is.EquivalentTo(expectedResults)); + } + + private void pushNewPlayer(Score score) + { + var player = new ReplayPlayer(score); + SelectedMods.Value = score.ScoreInfo.Mods; + player.OnLoadComplete += _ => + { + player.GameplayState.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == player) + results.Add(result); + }; + }; + LoadScreen(currentPlayer = player); + results.Clear(); + } + + private class TestScoreDecoder : LegacyScoreDecoder + { + private readonly WorkingBeatmap beatmap; + private readonly Ruleset ruleset; + + public TestScoreDecoder(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + this.beatmap = beatmap; + this.ruleset = ruleset.CreateInstance(); + } + + protected override Ruleset GetRuleset(int rulesetId) => ruleset; + protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmap; + } + } +} From 8273583fd07e42319d8febeeffbc61ff6faaed3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 16 Apr 2025 08:35:35 +0200 Subject: [PATCH 03/33] Fix code quality once more --- .../TestSceneLegacyReplayPlayback.cs | 6 +++--- .../TestSceneLegacyReplayPlayback.cs | 2 +- .../TestSceneLegacyReplayPlayback.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs index acd97b92a9..ea66386c9a 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs @@ -341,7 +341,7 @@ namespace osu.Game.Rulesets.Mania.Tests Replay = replay, ScoreInfo = new ScoreInfo { - Ruleset = CreateRuleset()!.RulesetInfo, + Ruleset = CreateRuleset().RulesetInfo, Mods = [new ModScoreV2()] } }; @@ -393,7 +393,7 @@ namespace osu.Game.Rulesets.Mania.Tests Replay = replay, ScoreInfo = new ScoreInfo { - Ruleset = CreateRuleset()!.RulesetInfo, + Ruleset = CreateRuleset().RulesetInfo, } }; @@ -442,7 +442,7 @@ namespace osu.Game.Rulesets.Mania.Tests Replay = replay, ScoreInfo = new ScoreInfo { - Ruleset = CreateRuleset()!.RulesetInfo, + Ruleset = CreateRuleset().RulesetInfo, Mods = [new ManiaModKey1()], } }; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs index 5a085fe17c..c22255bbdf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs @@ -108,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Tests Replay = replay, ScoreInfo = new ScoreInfo { - Ruleset = CreateRuleset()!.RulesetInfo, + Ruleset = CreateRuleset().RulesetInfo, } }; diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs index 4703b38e57..459312f2b4 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string? ExportLocation => null; - protected override Ruleset? CreateRuleset() => new TaikoRuleset(); + protected override Ruleset CreateRuleset() => new TaikoRuleset(); private static readonly object[][] test_cases = { @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Replay = replay, ScoreInfo = new ScoreInfo { - Ruleset = CreateRuleset()!.RulesetInfo, + Ruleset = CreateRuleset().RulesetInfo, } }; From 51ad6289ca4b2417c02ae928957f8726551982da Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 16 Apr 2025 20:21:48 +0900 Subject: [PATCH 04/33] Fix global offset adjust control showing adjustment available when it shouldn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio offset is integer based in configuration, so let's make sure not to show that there's an applicable offset when the value difference is too low. I've also fixed rounding to match expectations (`AudioOffset` is precision limited to integer), and handled the case where a user adjusts the slider but also has a suggested offset – previously it would not enable the button after slider adjustments but now it will work as expected. --- ...estSceneHitEventTimingDistributionGraph.cs | 13 +++++ .../TestSceneAudioOffsetAdjustControl.cs | 51 +++++++++++++++++++ osu.Game/Localisation/AudioSettingsStrings.cs | 5 ++ .../Audio/AudioOffsetAdjustControl.cs | 22 ++++++-- 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 760210c370..bb4b785db0 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -168,6 +168,19 @@ namespace osu.Game.Tests.Visual.Ranking }; }); + public static List CreateHitEvents(double offset = 0, int count = 50) + { + var hitEvents = new List(); + + for (int i = 0; i < count; i++) + { + for (int j = 0; j < count; j++) + hitEvents.Add(new HitEvent(offset, 1.0, HitResult.Perfect, placeholder_object, placeholder_object, null)); + } + + return hitEvents; + } + public static List CreateDistributedHitEvents(double centre = 0, double range = 25) { var hitEvents = new List(); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs index 85cde966b1..2fc5378ba1 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs @@ -1,11 +1,14 @@ // 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.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Overlays.Settings.Sections.Audio; @@ -70,16 +73,54 @@ namespace osu.Game.Tests.Visual.Settings AddStep("clear history", () => tracker.ClearHistory()); } + [Test] + public void TestRounding() + { + AddStep("set new score", () => statics.SetValue(Static.LastLocalUserScore, new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateHitEvents(0.6), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + })); + + checkButtonEnabled(); + AddStep("click button", () => adjustControl.ChildrenOfType