// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; using osu.Game.IO.Legacy; using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Beatmaps.Formats { [TestFixture] public class LegacyScoreDecoderTest { private CultureInfo originalCulture; [SetUp] public void SetUp() { originalCulture = CultureInfo.CurrentCulture; } [Test] public void TestDecodeManiaReplay() { var decoder = new TestLegacyScoreDecoder(); using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) { var score = decoder.Parse(resourceStream); Assert.AreEqual(3, score.ScoreInfo.Ruleset.OnlineID); Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]); Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]); Assert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore); Assert.AreEqual(3, score.ScoreInfo.MaxCombo); Assert.IsTrue(score.ScoreInfo.Mods.Any(m => m is ManiaModClassic)); Assert.IsTrue(score.ScoreInfo.APIMods.Any(m => m.Acronym == "CL")); Assert.IsTrue(score.ScoreInfo.ModsJson.Contains("CL")); Assert.That((2 * 300d + 1 * 200) / (3 * 305d), Is.EqualTo(score.ScoreInfo.Accuracy).Within(0.0001)); Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank); Assert.That(score.Replay.Frames, Is.Not.Empty); } } [Test] public void TestDecodeTaikoReplay() { var decoder = new TestLegacyScoreDecoder(); using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay.osr")) { var score = decoder.Parse(resourceStream); Assert.AreEqual(1, score.ScoreInfo.Ruleset.OnlineID); Assert.AreEqual(4, score.ScoreInfo.Statistics[HitResult.Great]); Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.LargeBonus]); Assert.AreEqual(4, score.ScoreInfo.MaxCombo); Assert.That(score.Replay.Frames, Is.Not.Empty); } } [Test] public void TestDecodeLegacyOnlineID() { var decoder = new TestLegacyScoreDecoder(); using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay-with-legacy-online-id.osr")) { var score = decoder.Parse(resourceStream); Assert.That(score.ScoreInfo.OnlineID, Is.EqualTo(-1)); Assert.That(score.ScoreInfo.LegacyOnlineID, Is.EqualTo(255)); } } [Test] public void TestDecodeNewOnlineID() { var decoder = new TestLegacyScoreDecoder(); using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay-with-new-online-id.osr")) { var score = decoder.Parse(resourceStream); Assert.That(score.ScoreInfo.OnlineID, Is.EqualTo(258)); Assert.That(score.ScoreInfo.LegacyOnlineID, Is.EqualTo(-1)); } } [TestCase(3, true)] [TestCase(6, false)] [TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)] public void TestLegacyBeatmapReplayOffsetsDecode(int beatmapVersion, bool offsetApplied) { const double first_frame_time = 48; const double second_frame_time = 65; var decoder = new TestLegacyScoreDecoder(beatmapVersion); using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) { var score = decoder.Parse(resourceStream); Assert.That(score.Replay.Frames[0].Time, Is.EqualTo(first_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0))); Assert.That(score.Replay.Frames[1].Time, Is.EqualTo(second_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0))); } } [TestCase(3)] [TestCase(6)] [TestCase(LegacyBeatmapDecoder.LATEST_VERSION)] public void TestLegacyBeatmapReplayOffsetsEncodeDecode(int beatmapVersion) { const double first_frame_time = 2000; const double second_frame_time = 3000; var ruleset = new OsuRuleset().RulesetInfo; var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); var beatmap = new TestBeatmap(ruleset) { BeatmapInfo = { BeatmapVersion = beatmapVersion } }; var score = new Score { ScoreInfo = scoreInfo, Replay = new Replay { Frames = new List { new OsuReplayFrame(first_frame_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), new OsuReplayFrame(second_frame_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) } } }; var decodedAfterEncode = encodeThenDecode(beatmapVersion, score, beatmap); Assert.That(decodedAfterEncode.Replay.Frames[0].Time, Is.EqualTo(first_frame_time)); Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(second_frame_time)); } [Test] public void TestCultureInvariance() { var ruleset = new OsuRuleset().RulesetInfo; var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); var beatmap = new TestBeatmap(ruleset); var score = new Score { ScoreInfo = scoreInfo, Replay = new Replay { Frames = new List { new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) } } }; // the "se" culture is used here, as it encodes the negative number sign as U+2212 MINUS SIGN, // rather than the classic ASCII U+002D HYPHEN-MINUS. CultureInfo.CurrentCulture = new CultureInfo("se"); var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); Assert.Multiple(() => { Assert.That(decodedAfterEncode, Is.Not.Null); Assert.That(decodedAfterEncode.ScoreInfo.User.Username, Is.EqualTo(scoreInfo.User.Username)); Assert.That(decodedAfterEncode.ScoreInfo.Ruleset, Is.EqualTo(scoreInfo.Ruleset)); Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(scoreInfo.TotalScore)); Assert.That(decodedAfterEncode.ScoreInfo.MaxCombo, Is.EqualTo(scoreInfo.MaxCombo)); Assert.That(decodedAfterEncode.ScoreInfo.Date, Is.EqualTo(scoreInfo.Date)); Assert.That(decodedAfterEncode.Replay.Frames.Count, Is.EqualTo(1)); }); } [Test] public void TestSoloScoreData() { var ruleset = new OsuRuleset().RulesetInfo; var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); scoreInfo.Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }; scoreInfo.OnlineID = 123123; scoreInfo.ClientVersion = "2023.1221.0"; var beatmap = new TestBeatmap(ruleset); var score = new Score { ScoreInfo = scoreInfo, Replay = new Replay { Frames = new List { new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) } } }; var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); Assert.Multiple(() => { Assert.That(decodedAfterEncode.ScoreInfo.OnlineID, Is.EqualTo(123123)); Assert.That(decodedAfterEncode.ScoreInfo.Statistics, Is.EqualTo(scoreInfo.Statistics)); Assert.That(decodedAfterEncode.ScoreInfo.MaximumStatistics, Is.EqualTo(scoreInfo.MaximumStatistics)); Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods)); Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0")); }); } [Test] public void AccuracyOfStableScoreRecomputed() { var memoryStream = new MemoryStream(); // local partial implementation of legacy score encoder // this is done half for readability, half because `LegacyScoreEncoder` forces `LATEST_VERSION` // and we want to emulate a stable score here using (var sw = new SerializationWriter(memoryStream, true)) { sw.Write((byte)3); // ruleset id (mania). // mania is used intentionally as it is the only ruleset wherein default accuracy calculation is changed in lazer sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable) sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test sw.Write("username"); // irrelevant to this test sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test sw.Write((ushort)1); // count300 sw.Write((ushort)0); // count100 sw.Write((ushort)0); // count50 sw.Write((ushort)198); // countGeki (perfects / "rainbow 300s" in mania) sw.Write((ushort)0); // countKatu sw.Write((ushort)1); // countMiss sw.Write(12345678); // total score, irrelevant to this test sw.Write((ushort)1000); // max combo, irrelevant to this test sw.Write(false); // full combo, irrelevant to this test sw.Write((int)LegacyMods.Hidden); // mods sw.Write(string.Empty); // hp graph, irrelevant sw.Write(DateTime.Now); // date, irrelevant sw.Write(Array.Empty()); // replay data, irrelevant sw.Write((long)1234); // legacy online ID, irrelevant } memoryStream.Seek(0, SeekOrigin.Begin); var decoded = new TestLegacyScoreDecoder().Parse(memoryStream); Assert.Multiple(() => { Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 305 + 300) / (200 * 305))); Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH)); }); } [Test] public void RankOfStableScoreUsesLazerDefinitions() { var memoryStream = new MemoryStream(); // local partial implementation of legacy score encoder // this is done half for readability, half because `LegacyScoreEncoder` forces `LATEST_VERSION` // and we want to emulate a stable score here using (var sw = new SerializationWriter(memoryStream, true)) { sw.Write((byte)0); // ruleset id (osu!) sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable) sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test sw.Write("username"); // irrelevant to this test sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test sw.Write((ushort)195); // count300 sw.Write((ushort)1); // count100 sw.Write((ushort)4); // count50 sw.Write((ushort)0); // countGeki sw.Write((ushort)0); // countKatu sw.Write((ushort)0); // countMiss sw.Write(12345678); // total score, irrelevant to this test sw.Write((ushort)1000); // max combo, irrelevant to this test sw.Write(false); // full combo, irrelevant to this test sw.Write((int)LegacyMods.Hidden); // mods sw.Write(string.Empty); // hp graph, irrelevant sw.Write(DateTime.Now); // date, irrelevant sw.Write(Array.Empty()); // replay data, irrelevant sw.Write((long)1234); // legacy online ID, irrelevant } memoryStream.Seek(0, SeekOrigin.Begin); var decoded = new TestLegacyScoreDecoder().Parse(memoryStream); Assert.Multiple(() => { // In stable this would be an A because there are over 1% 50s. But that's not a thing in lazer. Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH)); }); } [Test] public void AccuracyRankAndTotalScoreOfLazerScorePreserved() { var ruleset = new OsuRuleset().RulesetInfo; var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); scoreInfo.Mods = new Mod[] { new OsuModFlashlight() }; scoreInfo.Statistics = new Dictionary { [HitResult.Great] = 199, [HitResult.Miss] = 1, [HitResult.LargeTickHit] = 1, }; scoreInfo.MaximumStatistics = new Dictionary { [HitResult.Great] = 200, [HitResult.LargeTickHit] = 1, }; var beatmap = new TestBeatmap(ruleset); var score = new Score { ScoreInfo = scoreInfo, }; var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); Assert.Multiple(() => { Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(284_537)); Assert.That(decodedAfterEncode.ScoreInfo.LegacyTotalScore, Is.Null); Assert.That(decodedAfterEncode.ScoreInfo.Accuracy, Is.EqualTo((double)(199 * 300 + 30) / (200 * 300 + 30))); Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A)); }); } [Test] public void AccuracyAndRankOfLazerScoreWithoutLegacyReplaySoloScoreInfoUsesBestEffortFallbackToLegacy() { var memoryStream = new MemoryStream(); // local partial implementation of legacy score encoder // this is done half for readability, half because we want to emulate an old lazer score here // that does not have everything that `LegacyScoreEncoder` now writes to the replay using (var sw = new SerializationWriter(memoryStream, true)) { sw.Write((byte)0); // ruleset id (osu!) sw.Write(LegacyScoreEncoder.FIRST_LAZER_VERSION); // version sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test sw.Write("username"); // irrelevant to this test sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test sw.Write((ushort)198); // count300 sw.Write((ushort)0); // count100 sw.Write((ushort)1); // count50 sw.Write((ushort)0); // countGeki sw.Write((ushort)0); // countKatu sw.Write((ushort)1); // countMiss sw.Write(12345678); // total score, irrelevant to this test sw.Write((ushort)1000); // max combo, irrelevant to this test sw.Write(false); // full combo, irrelevant to this test sw.Write((int)LegacyMods.Hidden); // mods sw.Write(string.Empty); // hp graph, irrelevant sw.Write(DateTime.Now); // date, irrelevant sw.Write(Array.Empty()); // replay data, irrelevant sw.Write((long)1234); // legacy online ID, irrelevant // importantly, no compressed `LegacyReplaySoloScoreInfo` here } memoryStream.Seek(0, SeekOrigin.Begin); var decoded = new TestLegacyScoreDecoder().Parse(memoryStream); Assert.Multiple(() => { Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 300 + 50) / (200 * 300))); Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A)); }); } private static Score encodeThenDecode(int beatmapVersion, Score score, TestBeatmap beatmap) { var encodeStream = new MemoryStream(); var encoder = new LegacyScoreEncoder(score, beatmap); encoder.Encode(encodeStream); var decodeStream = new MemoryStream(encodeStream.GetBuffer()); var decoder = new TestLegacyScoreDecoder(beatmapVersion); var decodedAfterEncode = decoder.Parse(decodeStream); return decodedAfterEncode; } [TearDown] public void TearDown() { CultureInfo.CurrentCulture = originalCulture; } public class TestLegacyScoreDecoder : LegacyScoreDecoder { private readonly int beatmapVersion; private static readonly Dictionary rulesets = new Ruleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() }.ToDictionary(ruleset => ((ILegacyRuleset)ruleset).LegacyID); public TestLegacyScoreDecoder(int beatmapVersion = LegacyBeatmapDecoder.LATEST_VERSION) { this.beatmapVersion = beatmapVersion; } protected override Ruleset GetRuleset(int rulesetId) => rulesets[rulesetId]; protected override WorkingBeatmap GetBeatmap(string md5Hash) => new TestWorkingBeatmap(new Beatmap { BeatmapInfo = new BeatmapInfo { MD5Hash = md5Hash, Ruleset = new OsuRuleset().RulesetInfo, Difficulty = new BeatmapDifficulty(), BeatmapVersion = beatmapVersion, }, // needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die // when trying to recompute total score. HitObjects = { new HitCircle() } }); } } }