1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-27 06:29:54 +08:00
Files
osu-lazer/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs
T
Bartłomiej Dach 9727d95ad9 Replace usages of Mod.ScoreMultiplier with new score multiplier API (#37845)
- Part of https://github.com/ppy/osu/issues/37818

During review, I would like to direct particular attention to the
following changes:

## [Migrate song select to new score multiplier
API](https://github.com/ppy/osu/commit/945fd78539da3ae57d1550a5bbfb0f859d153cc4)

This was a confusing change to write because of the way song selects
hook their mod overlays up to global bindables. In particular different
things happen in different circumstances.

- When going through `SongSelect.CreateModOverlay()`, which is called by
the base `SongSelect`, the mod overlay is automatically bound to global
bindables via `SongSelect.on{ArrivingAt,Leaving}Screen()`.
- For multiplayer user mod select overlays, which are bolted on by
subclasses of `SongSelect`, manual hook-up is required.
- As for free mod select overlays, they don't show mod multipliers at
all, and don't have easy access to the ruleset, and thus the hookup is
skipped entirely as redundant.

## [Fix score multiplier registrations being shared between
implementations via superclass static
fields](https://github.com/ppy/osu/commit/ba0a7ad421e0c84c2d8162b6bbdd3a0683f5a6a6)

Revealed by `ScoreMultiplierCalculatorTest` starting to fail due to
interference from `OsuScoreMultiplierCalculator`.

It's not ideal from a performance standpoint but it's the simplest
choice for now. Tricks could be pulled to salvage the static. One is

```csharp
public class ScoreMultiplierCalculator<T>
	where T : ScoreMultiplierCalculator<T>
{
}
```

This works because of generics internals; static instance members are
not shared between different specialisations of a generic class. It is
also very unintuitive, so I would rather not. (It trips a ReSharper
inspection too, which would have to be silenced.)

From a performance standpoint this is not ideal, but a significant chunk
of migrated usages already precede the construction of the calculator
via the known-expensive `RulesetInfo.CreateInstance()`, and the paths
that actually construct the calculator do not appear to be that hot. If
need be, this can be handled by actually caching ruleset instances and
their derivative subcomponents.

## [Introduce passing of context to score multiplier
calculator](https://github.com/ppy/osu/pull/37845/changes/9e9242b3221dddacd226f4b3b9c5632d7350e998)

This is required for two reasons:

- The upcoming mod rebalance will require out-of-band supplementary
information that is not available for reading from the mod instances
themselves for calculating the multiplier.
- This context, namely passing of `ScoreInfo`, will be used for
implementing backwards compatibility with old scores and their score
multipliers. This is required because it has turned out under inspection
that all server-side lazer replays recorded until now are missing
`TotalScoreWithoutMods` due to an omission of not sending it across the
wire to spectator server.

Because the score import flow uses replays, filtered through
`LegacyScoreDecoder`, to populate total score in the realm database, it
is basically impossible to ignore scores that are missing
`TotalScoreWithoutMods`, because that will result in bug reports that
the scores do not have the new score multipliers applied.

Thus, passing of `ScoreInfo` will facilitate implementation of
versioning score multipliers, which should result in less breakage than
not doing so.

An example of this is added in 341b2d6e55,
which should handle the case of mania mod multipliers having been
changed without any attempt to facilitate for it in
https://github.com/ppy/osu/pull/30506.

---------

Co-authored-by: Dean Herbert <pe@ppy.sh>
2026-05-26 18:02:15 +09:00

393 lines
17 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO.Legacy;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Replays;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osuTK;
using SharpCompress.Compressors.LZMA;
namespace osu.Game.Scoring.Legacy
{
public abstract class LegacyScoreDecoder
{
private IBeatmap currentBeatmap;
private Ruleset currentRuleset;
private long beatmapOffset;
public Score Parse(Stream stream)
{
var score = new Score
{
Replay = new Replay()
};
WorkingBeatmap workingBeatmap;
ScoreRank? decodedRank = null;
using (SerializationReader sr = new SerializationReader(stream))
{
currentRuleset = GetRuleset(sr.ReadByte());
var scoreInfo = new ScoreInfo { Ruleset = currentRuleset.RulesetInfo };
score.ScoreInfo = scoreInfo;
int version = sr.ReadInt32();
scoreInfo.IsLegacyScore = version < LegacyScoreEncoder.FIRST_LAZER_VERSION;
// TotalScoreVersion gets initialised to LATEST_VERSION.
// In the case where the incoming score has either an osu!stable or old lazer version, we need
// to mark it with the correct version increment to trigger reprocessing to new standardised scoring.
//
// See StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised().
scoreInfo.TotalScoreVersion = version < 30000002 ? 30000001 : LegacyScoreEncoder.LATEST_VERSION;
string beatmapHash = sr.ReadString();
workingBeatmap = GetBeatmap(beatmapHash);
if (workingBeatmap is DummyWorkingBeatmap)
throw new BeatmapNotFoundException(beatmapHash);
scoreInfo.User = new APIUser { Username = sr.ReadString() };
// MD5Hash
sr.ReadString();
scoreInfo.SetCount300(sr.ReadUInt16());
scoreInfo.SetCount100(sr.ReadUInt16());
scoreInfo.SetCount50(sr.ReadUInt16());
scoreInfo.SetCountGeki(sr.ReadUInt16());
scoreInfo.SetCountKatu(sr.ReadUInt16());
scoreInfo.SetCountMiss(sr.ReadUInt16());
scoreInfo.TotalScore = sr.ReadInt32();
scoreInfo.MaxCombo = sr.ReadUInt16();
/* score.Perfect = */
sr.ReadBoolean();
scoreInfo.Mods = currentRuleset.ConvertFromLegacyMods((LegacyMods)sr.ReadInt32()).ToArray();
// lazer replays get a really high version number.
if (version < LegacyScoreEncoder.FIRST_LAZER_VERSION)
scoreInfo.Mods = scoreInfo.Mods.Append(currentRuleset.CreateMod<ModClassic>()).ToArray();
currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods);
scoreInfo.BeatmapInfo = currentBeatmap.BeatmapInfo;
// As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing.
beatmapOffset = currentBeatmap.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0;
/* score.HpGraphString = */
sr.ReadString();
scoreInfo.Date = sr.ReadDateTime();
byte[] compressedReplay = sr.ReadByteArray();
if (version >= 20140721)
scoreInfo.LegacyOnlineID = sr.ReadInt64();
else if (version >= 20121008)
scoreInfo.LegacyOnlineID = sr.ReadInt32();
if (scoreInfo.LegacyOnlineID == 0)
scoreInfo.LegacyOnlineID = -1;
byte[] compressedScoreInfo = null;
if (version >= 30000001)
compressedScoreInfo = sr.ReadByteArray();
if (compressedReplay?.Length > 0)
readCompressedData(compressedReplay, reader => readLegacyReplay(score.Replay, reader));
if (compressedScoreInfo?.Length > 0)
{
readCompressedData(compressedScoreInfo, reader =>
{
LegacyReplaySoloScoreInfo readScore = JsonConvert.DeserializeObject<LegacyReplaySoloScoreInfo>(reader.ReadToEnd());
Debug.Assert(readScore != null);
score.ScoreInfo.OnlineID = readScore.OnlineID;
score.ScoreInfo.Statistics = readScore.Statistics;
score.ScoreInfo.MaximumStatistics = readScore.MaximumStatistics;
score.ScoreInfo.Mods = readScore.Mods.Select(m => m.ToMod(currentRuleset)).ToArray();
score.ScoreInfo.ClientVersion = readScore.ClientVersion;
decodedRank = readScore.Rank;
if (readScore.UserID > 1)
score.ScoreInfo.RealmUser.OnlineID = readScore.UserID;
if (readScore.TotalScoreWithoutMods is long totalScoreWithoutMods)
score.ScoreInfo.TotalScoreWithoutMods = totalScoreWithoutMods;
else
PopulateTotalScoreWithoutMods(score.ScoreInfo);
score.ScoreInfo.Pauses.AddRange(readScore.Pauses);
});
}
}
PopulateMaximumStatistics(score.ScoreInfo, workingBeatmap);
if (score.ScoreInfo.IsLegacyScore)
score.ScoreInfo.LegacyTotalScore = score.ScoreInfo.TotalScore;
StandardisedScoreMigrationTools.UpdateFromLegacy(score.ScoreInfo, workingBeatmap);
if (decodedRank != null)
score.ScoreInfo.Rank = decodedRank.Value;
// before returning for database import, we must restore the database-sourced BeatmapInfo.
// if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception.
score.ScoreInfo.BeatmapInfo = workingBeatmap.BeatmapInfo;
score.ScoreInfo.BeatmapHash = workingBeatmap.BeatmapInfo.Hash;
return score;
}
private void readCompressedData(byte[] data, Action<StreamReader> readFunc)
{
using (var replayInStream = new MemoryStream(data))
{
byte[] properties = new byte[5];
if (replayInStream.Read(properties, 0, 5) != 5)
throw new IOException("input .lzma is too short");
long outSize = 0;
for (int i = 0; i < 8; i++)
{
int v = replayInStream.ReadByte();
if (v < 0)
throw new IOException("Can't Read 1");
outSize |= (long)(byte)v << (8 * i);
}
long compressedSize = replayInStream.Length - replayInStream.Position;
using (var lzma = LzmaStream.Create(properties, replayInStream, compressedSize, outSize))
using (var reader = new StreamReader(lzma))
readFunc(reader);
}
}
/// <summary>
/// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary>
/// <param name="score">The score to populate the statistics of.</param>
/// <param name="workingBeatmap">The corresponding <see cref="WorkingBeatmap"/>.</param>
public static void PopulateMaximumStatistics(ScoreInfo score, WorkingBeatmap workingBeatmap)
{
Debug.Assert(score.BeatmapInfo != null);
if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
return;
var ruleset = score.Ruleset.Detach();
var rulesetInstance = ruleset.CreateInstance();
var scoreProcessor = rulesetInstance.CreateScoreProcessor();
// Populate the maximum statistics.
HitResult maxBasicResult = rulesetInstance.GetHitResultsForDisplay()
.Select(h => h.result)
.Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
foreach ((HitResult result, int count) in score.Statistics)
{
switch (result)
{
case HitResult.LargeTickHit:
case HitResult.LargeTickMiss:
score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
break;
case HitResult.SmallTickHit:
case HitResult.SmallTickMiss:
score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
break;
case HitResult.IgnoreHit:
case HitResult.IgnoreMiss:
case HitResult.SmallBonus:
case HitResult.LargeBonus:
break;
default:
score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
break;
}
}
if (!score.IsLegacyScore)
return;
#pragma warning disable CS0618
// In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
// A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
var calculator = rulesetInstance.CreateDifficultyCalculator(workingBeatmap);
var attributes = calculator.Calculate(score.Mods);
int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum();
if (attributes.MaxCombo > maxComboFromStatistics)
score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics;
#pragma warning restore CS0618
}
public static void PopulateTotalScoreWithoutMods(ScoreInfo score)
{
var ruleset = score.Ruleset.CreateInstance();
var scoreMultiplierCalculator = ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(score));
double modMultiplier = scoreMultiplierCalculator.CalculateFor(score.Mods);
score.TotalScoreWithoutMods = (long)Math.Round(score.TotalScore / modMultiplier);
}
private void readLegacyReplay(Replay replay, StreamReader reader)
{
long lastTime = beatmapOffset;
var legacyFrames = new List<LegacyReplayFrame>();
string[] frames = reader.ReadToEnd().Split(',');
for (int i = 0; i < frames.Length; i++)
{
string[] split = frames[i].Split('|');
if (split.Length < 4)
continue;
if (split[0] == "-12345")
{
// Todo: The seed is provided in split[3], which we'll need to use at some point
continue;
}
// In mania, mouseX encodes the pressed keys in the lower 20 bits
int mouseXParseLimit = currentRuleset.RulesetInfo.OnlineID == 3 ? (1 << 20) - 1 : Parsing.MAX_COORDINATE_VALUE;
// the legacy replay format as defined by stable expects frame delta times
// ('delta time' here meaning the amount of time between consecutive frames)
// to be integral and does not allow fractional values.
// one particular reason why this matters is that integral deltas
// avoid nasty floating point traps like accumulation error from summation or round-off error.
// however, there was a period in lazer's lifetime wherein lazer emitted replays
// with fractional (float) frame deltas, up until https://github.com/ppy/osu/pull/12583.
// despite the fact that gameplay mechanics changed multiple times since
// and the replay isn't going to play back anywhere near accurately anyway,
// no mistakes are ever forgiven, thus this attempts to parse the delta as an integer once,
// and if that fails, tries again as float.
// notably this cannot just be `(int)Parsing.ParseFloat(split[0])`, because that can lose information
// (`float` numbers have 24 bits of significand precision, which is not enough to accurately represent every possible value of `int`).
int diff;
if (!int.TryParse(split[0], out diff))
diff = (int)Math.Round(Parsing.ParseFloat(split[0]));
float mouseX = Parsing.ParseFloat(split[1], mouseXParseLimit);
float mouseY = Parsing.ParseFloat(split[2], Parsing.MAX_COORDINATE_VALUE);
lastTime += diff;
legacyFrames.Add(new LegacyReplayFrame(lastTime,
mouseX,
mouseY,
(ReplayButtonState)Parsing.ParseInt(split[3])));
}
// https://github.com/peppy/osu-stable-reference/blob/e53980dd76857ee899f66ce519ba1597e7874f28/osu!/GameModes/Play/ReplayWatcher.cs#L62-L67
if (legacyFrames.Count >= 2 && legacyFrames[1].Time < legacyFrames[0].Time)
{
legacyFrames[1].Time = legacyFrames[0].Time;
legacyFrames[0].Time = 0;
}
// https://github.com/peppy/osu-stable-reference/blob/e53980dd76857ee899f66ce519ba1597e7874f28/osu!/GameModes/Play/ReplayWatcher.cs#L69-L71
if (legacyFrames.Count >= 3 && legacyFrames[0].Time > legacyFrames[2].Time)
legacyFrames[0].Time = legacyFrames[1].Time = legacyFrames[2].Time;
// at the start of the replay, stable places two replay frames, at time 0 and SkipBoundary - 1, respectively.
// both frames use a position of (256, -500).
// ignore these frames as they serve no real purpose (and can even mislead ruleset-specific handlers - see mania)
if (legacyFrames.Count >= 2 && legacyFrames[1].Position == new Vector2(256, -500))
legacyFrames.RemoveAt(1);
if (legacyFrames.Count >= 1 && legacyFrames[0].Position == new Vector2(256, -500))
legacyFrames.RemoveAt(0);
ReplayFrame currentFrame = null;
foreach (var legacyFrame in legacyFrames)
{
// never allow backwards time traversal in relation to the current frame.
// this handles frames with negative delta.
// this doesn't match stable 100% as stable will do something similar to adding an interpolated "intermediate frame"
// at the point wherein time flow changes from backwards to forwards, but it'll do for now.
if (currentFrame != null && legacyFrame.Time < currentFrame.Time)
continue;
replay.Frames.Add(currentFrame = convertFrame(legacyFrame, currentFrame));
}
}
private ReplayFrame convertFrame(LegacyReplayFrame currentFrame, ReplayFrame lastFrame)
{
var convertible = currentRuleset.CreateConvertibleReplayFrame();
if (convertible == null)
throw new InvalidOperationException($"Legacy replay cannot be converted for the ruleset: {currentRuleset.Description}");
convertible.FromLegacy(currentFrame, currentBeatmap, lastFrame);
var frame = (ReplayFrame)convertible;
frame.Time = currentFrame.Time;
return frame;
}
/// <summary>
/// Retrieves the <see cref="Ruleset"/> for a specific id.
/// </summary>
/// <param name="rulesetId">The id.</param>
/// <returns>The <see cref="Ruleset"/>.</returns>
protected abstract Ruleset GetRuleset(int rulesetId);
/// <summary>
/// Retrieves the <see cref="WorkingBeatmap"/> corresponding to an MD5 hash.
/// </summary>
/// <param name="md5Hash">The MD5 hash.</param>
/// <returns>The <see cref="WorkingBeatmap"/>.</returns>
protected abstract WorkingBeatmap GetBeatmap(string md5Hash);
public class BeatmapNotFoundException : Exception
{
public string Hash { get; }
public BeatmapNotFoundException(string hash)
{
Hash = hash;
}
}
}
}