mirror of
https://github.com/ppy/osu.git
synced 2026-05-16 23:43:27 +08:00
82b2a92894
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.
158 lines
6.5 KiB
C#
158 lines
6.5 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
|
|
{
|
|
/// <summary>
|
|
/// The goal of this abstract test class is to exercise correct playback of replays sourced from previous osu! versions.
|
|
/// Use <see cref="RunTest"/> to exercise that property.
|
|
/// </summary>
|
|
[HeadlessTest]
|
|
[TestFixture]
|
|
public abstract partial class LegacyReplayPlaybackTestScene : RateAdjustedBeatmapTestScene
|
|
{
|
|
private ReplayPlayer currentPlayer = null!;
|
|
private readonly List<JudgementResult> results = new List<JudgementResult>();
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
protected abstract string? ExportLocation { get; }
|
|
|
|
/// <summary>
|
|
/// Encodes the supplied <paramref name="originalScore"/>, decodes the result of encoding, runs the result of decoding against the supplied <paramref name="beatmap"/>,
|
|
/// and checks that the judgement results recorded still match <paramref name="expectedResults"/>.
|
|
/// If <see cref="ExportLocation"/> is set, exports both the beatmap and the replay to said location.
|
|
/// </summary>
|
|
protected void RunTest(string beatmapName, IBeatmap beatmap, string replayName, Score originalScore, IEnumerable<HitResult> 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;
|
|
}
|
|
}
|
|
}
|