1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-28 04:49:54 +08:00
Files
osu-lazer/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs
T
Bartłomiej Dach 0e443b1c47 Add legacy storyboard encoder (#37790)
- Closes https://github.com/ppy/osu/issues/37757

Commit-by-commit reading is recommended. Commits will be split to PRs on
request but I consider this to be the minimal viable functional
increment.

## Done

- This adds a first version of a full storyboard encoder
(a66dc406f498e35d4e0c8f2a462e946a9a1aeccc). I expect there to be hiccups
due to weird corners of the `.osb` format; this is only intended to be
somewhat correct as a start to build upon. Storyboarders are asked to
file issues as necessary.
- Due to the fact that storyboard definitions can reside both in the
`.osu` and the `.osb`, b60698a95c4de1bfeb36fbb159fd5a6028920832 adds the
required storage to be able to tell which storyboard element lives
where, so that it can be decoded properly later.
- In c9d3e04a4135886b5b0943c85f3cc6f4fe99c84c, the storyboard decoder is
weaved into the beatmap decoder to handle the `.osu` part of the
storyboard, via the
`LegacyStoryboardEncoder.Encode{General,Events}ToBeatmap()` methods. For
`.osb`s, `LegacyStoryboardEncoder.EncodeStandaloneStoryboard()` is
intended, but for now is not used outside tests.
- Because of the above, dd1c4e43dc51154cd67860f096712f8b4f229661 removes
`Beatmap.UnhandledEventLines` as no longer required.
- 26ac417ed98a8937c42e5f52c4e15ef065a48902 adds tests. They are mostly
handwritten to ensure basic encode-decode roundtripping. Using existing
storyboards is difficult, see "Known issues" section as to why.
- 5cc542366db7caac38eb0729260d884905a2c0d5 fixes a bug in the storyboard
decoder where the trigger group number was not properly negated on
decode (see inline comment reference to relevant stable code).

## Known issues

- Any and all variables in the `[Variables]` section are inlined into
their usages by `LegacyStoryboardDecoder`, and as such
`LegacyStoryboardEncoder` will end up inlining them and discarding the
`[Variables]` section. As far as I can tell stable will also do this.
- `LegacyStoryboardDecoder` splits all `M` (move) commands into
`MX`/`MY` commands. Therefore, `LegacyStoryboardEncoder` will write out
things in the same split way. I did not put in effort to attempt to
reconcile this, for reasons of part laziness, part not wanting to bloat
this already-large diff.
- Ordering of storyboard samples on decode may not match the order on
decode. I'm crossing fingers this doesn't matter.
2026-05-20 17:46:51 +09:00

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, 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;
}
}
}