2020-03-24 11:06:24 +08:00
// 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 ;
2022-07-03 19:27:56 +08:00
using System.Diagnostics ;
2020-03-24 11:06:24 +08:00
using System.IO ;
2020-03-24 13:13:46 +08:00
using System.Linq ;
using System.Text ;
using osu.Framework.Extensions ;
using osu.Game.Beatmaps ;
2022-03-24 15:43:41 +08:00
using osu.Game.Beatmaps.Formats ;
2022-03-04 06:09:56 +08:00
using osu.Game.Extensions ;
2020-03-24 13:13:46 +08:00
using osu.Game.IO.Legacy ;
2022-12-06 19:10:51 +08:00
using osu.Game.IO.Serialization ;
2022-02-28 15:37:14 +08:00
using osu.Game.Replays.Legacy ;
Fix incorrect score conversion on selected beatmaps due to incorrect `difficultyPeppyStars` rounding
Fixes issue that occurs on *about* 246 beatmaps and was first described
by me on discord:
https://discord.com/channels/188630481301012481/188630652340404224/1154367700378865715
and then rediscovered again during work on
https://github.com/ppy/osu/pull/26405:
https://gist.github.com/bdach/414d5289f65b0399fa8f9732245a4f7c#venenog-on-ultmate-end-by-blacky-overdose-631
It so happens that in stable, due to .NET Framework internals, float
math would be performed using x87 registers and opcodes.
.NET (Core) however uses SSE instructions on 32- and 64-bit words.
x87 registers are _80 bits_ wide. Which is notably wider than _both_
float and double. Therefore, on a significant number of beatmaps,
the rounding would not produce correct values due to insufficient
precision.
See following gist for corroboration of the above:
https://gist.github.com/bdach/dcde58d5a3607b0408faa3aa2b67bf10
Thus, to crudely - but, seemingly accurately, after checking across
all ranked maps - emulate this, use `decimal`, which is slow, but has
bigger precision than `double`. The single known exception beatmap
in whose case this results in an incorrect result is
https://osu.ppy.sh/beatmapsets/1156087#osu/2625853
which is considered an "acceptable casualty" of sorts.
Doing this requires some fooling of the compiler / runtime (see second
inline comment in new method). To corroborate that this is required,
you can try the following code snippet:
Console.WriteLine(string.Join(' ', BitConverter.GetBytes(1.3f).Select(x => x.ToString("X2"))));
Console.WriteLine(string.Join(' ', BitConverter.GetBytes(1.3).Select(x => x.ToString("X2"))));
Console.WriteLine();
decimal d1 = (decimal)1.3f;
decimal d2 = (decimal)1.3;
decimal d3 = (decimal)(double)1.3f;
Console.WriteLine(string.Join(' ', decimal.GetBits(d1).SelectMany(BitConverter.GetBytes).Select(x => x.ToString("X2"))));
Console.WriteLine(string.Join(' ', decimal.GetBits(d2).SelectMany(BitConverter.GetBytes).Select(x => x.ToString("X2"))));
Console.WriteLine(string.Join(' ', decimal.GetBits(d3).SelectMany(BitConverter.GetBytes).Select(x => x.ToString("X2"))));
which will print
66 66 A6 3F
CD CC CC CC CC CC F4 3F
0D 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00
0D 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00
8C 5D 89 FB 3B 76 00 00 00 00 00 00 00 00 0E 00
Note that despite `d1` being converted from a less-precise floating-
-point value than `d2`, it still is represented 100% accurately as
a decimal number.
After applying this change, recomputation of legacy scoring attributes
for *all* rulesets will be required.
2024-01-10 23:51:40 +08:00
using osu.Game.Rulesets.Objects.Legacy ;
2022-02-28 15:40:00 +08:00
using osu.Game.Rulesets.Replays ;
2020-03-24 13:13:46 +08:00
using osu.Game.Rulesets.Replays.Types ;
using SharpCompress.Compressors.LZMA ;
2020-03-24 11:06:24 +08:00
namespace osu.Game.Scoring.Legacy
{
public class LegacyScoreEncoder
{
2021-06-08 16:58:57 +08:00
/// <summary>
/// Database version in stable-compatible YYYYMMDD format.
2021-06-08 17:38:47 +08:00
/// Should be incremented if any changes are made to the format/usage.
2021-06-08 16:58:57 +08:00
/// </summary>
2022-12-14 17:30:31 +08:00
/// <remarks>
/// <list type="bullet">
/// <item><description>30000001: Appends <see cref="LegacyReplaySoloScoreInfo"/> to the end of scores.</description></item>
2023-07-04 16:53:53 +08:00
/// <item><description>30000002: Score stored to replay calculated using the Score V2 algorithm. Legacy scores on this version are candidate to Score V1 -> V2 conversion.</description></item>
2023-06-28 14:04:13 +08:00
/// <item><description>30000003: First version after converting legacy total score to standardised.</description></item>
2023-10-02 15:54:46 +08:00
/// <item><description>30000004: Fixed mod multipliers during legacy score conversion. Reconvert all scores.</description></item>
2023-11-20 06:53:05 +08:00
/// <item><description>30000005: Introduce combo exponent in the osu! gamemode. Reconvert all scores.</description></item>
2023-12-19 04:56:56 +08:00
/// <item><description>30000006: Fix edge cases in conversion after combo exponent introduction that lead to NaNs. Reconvert all scores.</description></item>
2023-12-20 22:58:26 +08:00
/// <item><description>30000007: Adjust osu!mania combo and accuracy portions and judgement scoring values. Reconvert all scores.</description></item>
2023-12-21 14:32:34 +08:00
/// <item><description>30000008: Add accuracy conversion. Reconvert all scores.</description></item>
2023-12-23 20:25:20 +08:00
/// <item><description>30000009: Fix edge cases in conversion for scores which have 0.0x mod multiplier on stable. Reconvert all scores.</description></item>
Fix mania score conversion using score V1 accuracy
Partially addresses https://github.com/ppy/osu/discussions/26416
As pointed out in the discussion thread above, the total score
conversion process for mania was using accuracy directly from the
replay. In mania accuracy is calculated differently in score V1 than in
score V2, which meant that scores coming from stable were treated more
favourably (due to weighting GREAT and PERFECT equally).
To fix, recompute accuracy locally and use that for the accuracy
portion.
Note that this will still not be (and cannot be made) 100% accurate, as
in stable score V2, as well as in lazer, hold notes are *two*
judgements, not one as in stable score V1, meaning that full and correct
score statistics are not available without playing back the replay.
The effects of the change can be previewed on the following spreadsheet:
https://docs.google.com/spreadsheets/d/1wxD4UwLjwcr7n9y5Yq7EN0lgiLBN93kpd4gBnAlG-E0/edit#gid=1711190356
Top 5 changed scores with replays:
| score | master | this PR | replay |
| :------------------------------------------------------------------------------------------------------------------------------- | ------: | ------: | ------: |
| [Outlasted on Uwa!! So Holiday by toby fox [[4K] easy] (0.71\*)](https://osu.ppy.sh/scores/mania/460404716) | 935,917 | 927,269 | 920,579 |
| [ag0 on Emotional Uplifting Orchestral by bradbreeck [[4K] Rocket's Normal] (0.76\*)](https://osu.ppy.sh/scores/mania/453133066) | 921,636 | 913,535 | 875,549 |
| [rlarkgus on Zen Zen Zense by Gom (HoneyWorks) [[5K] Normal] (1.68\*)](https://osu.ppy.sh/scores/mania/458368312) | 934,340 | 926,787 | 918,855 |
| [YuJJun on Harumachi Clover by R3 Music Box [4K Catastrophe] (1.80\*)](https://osu.ppy.sh/scores/mania/548215786) | 918,606 | 911,111 | 885,454 |
| [Fritte on 45-byou by respon feat. Hatsune Miku & Megpoid [[5K] Normal] (1.52\*)](https://osu.ppy.sh/scores/mania/516079410) | 900,024 | 892,569 | 907,456 |
2024-01-08 16:43:53 +08:00
/// <item><description>30000010: Fix mania score V1 conversion using score V1 accuracy rather than V2 accuracy. Reconvert all scores.</description></item>
2024-01-09 22:49:37 +08:00
/// <item><description>30000011: Re-do catch scoring to mirror stable Score V2 as closely as feasible. Reconvert all scores.</description></item>
Fix incorrect score conversion on selected beatmaps due to incorrect `difficultyPeppyStars` rounding
Fixes issue that occurs on *about* 246 beatmaps and was first described
by me on discord:
https://discord.com/channels/188630481301012481/188630652340404224/1154367700378865715
and then rediscovered again during work on
https://github.com/ppy/osu/pull/26405:
https://gist.github.com/bdach/414d5289f65b0399fa8f9732245a4f7c#venenog-on-ultmate-end-by-blacky-overdose-631
It so happens that in stable, due to .NET Framework internals, float
math would be performed using x87 registers and opcodes.
.NET (Core) however uses SSE instructions on 32- and 64-bit words.
x87 registers are _80 bits_ wide. Which is notably wider than _both_
float and double. Therefore, on a significant number of beatmaps,
the rounding would not produce correct values due to insufficient
precision.
See following gist for corroboration of the above:
https://gist.github.com/bdach/dcde58d5a3607b0408faa3aa2b67bf10
Thus, to crudely - but, seemingly accurately, after checking across
all ranked maps - emulate this, use `decimal`, which is slow, but has
bigger precision than `double`. The single known exception beatmap
in whose case this results in an incorrect result is
https://osu.ppy.sh/beatmapsets/1156087#osu/2625853
which is considered an "acceptable casualty" of sorts.
Doing this requires some fooling of the compiler / runtime (see second
inline comment in new method). To corroborate that this is required,
you can try the following code snippet:
Console.WriteLine(string.Join(' ', BitConverter.GetBytes(1.3f).Select(x => x.ToString("X2"))));
Console.WriteLine(string.Join(' ', BitConverter.GetBytes(1.3).Select(x => x.ToString("X2"))));
Console.WriteLine();
decimal d1 = (decimal)1.3f;
decimal d2 = (decimal)1.3;
decimal d3 = (decimal)(double)1.3f;
Console.WriteLine(string.Join(' ', decimal.GetBits(d1).SelectMany(BitConverter.GetBytes).Select(x => x.ToString("X2"))));
Console.WriteLine(string.Join(' ', decimal.GetBits(d2).SelectMany(BitConverter.GetBytes).Select(x => x.ToString("X2"))));
Console.WriteLine(string.Join(' ', decimal.GetBits(d3).SelectMany(BitConverter.GetBytes).Select(x => x.ToString("X2"))));
which will print
66 66 A6 3F
CD CC CC CC CC CC F4 3F
0D 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00
0D 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00
8C 5D 89 FB 3B 76 00 00 00 00 00 00 00 00 0E 00
Note that despite `d1` being converted from a less-precise floating-
-point value than `d2`, it still is represented 100% accurately as
a decimal number.
After applying this change, recomputation of legacy scoring attributes
for *all* rulesets will be required.
2024-01-10 23:51:40 +08:00
/// <item><description>
/// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct
/// <see cref="LegacyRulesetExtensions.CalculateDifficultyPeppyStars"/> method. Reconvert all scores.
/// </description></item>
2024-01-22 19:55:17 +08:00
/// <item><description>30000013: All local scores will use lazer definitions of ranks for consistency. Recalculates the rank of all scores.</description></item>
2024-02-24 20:20:42 +08:00
/// <item><description>30000014: Fix edge cases in conversion for osu! scores on selected beatmaps. Reconvert all scores.</description></item>
2024-03-07 04:30:09 +08:00
/// <item><description>30000015: Fix osu! standardised score estimation algorithm violating basic invariants. Reconvert all scores.</description></item>
2022-12-14 17:30:31 +08:00
/// </list>
/// </remarks>
2024-03-07 04:30:09 +08:00
public const int LATEST_VERSION = 30000015 ;
2021-06-08 17:38:47 +08:00
/// <summary>
/// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.
/// </summary>
2022-12-08 10:30:18 +08:00
public const int FIRST_LAZER_VERSION = 30000000 ;
2020-03-24 11:06:24 +08:00
private readonly Score score ;
2022-02-28 15:41:39 +08:00
private readonly IBeatmap ? beatmap ;
2020-03-24 11:06:24 +08:00
2022-02-28 15:37:14 +08:00
/// <summary>
/// Create a new score encoder for a specific score.
/// </summary>
/// <param name="score">The score to be encoded.</param>
/// <param name="beatmap">The beatmap used to convert frames for the score. May be null if the frames are already <see cref="LegacyReplayFrame"/>s.</param>
/// <exception cref="ArgumentException"></exception>
2022-02-28 15:41:39 +08:00
public LegacyScoreEncoder ( Score score , IBeatmap ? beatmap )
2020-03-24 11:06:24 +08:00
{
this . score = score ;
2020-03-24 13:13:46 +08:00
this . beatmap = beatmap ;
2020-03-24 11:06:24 +08:00
2022-02-28 15:37:14 +08:00
if ( beatmap = = null & & ! score . Replay . Frames . All ( f = > f is LegacyReplayFrame ) )
2022-02-28 15:41:39 +08:00
throw new ArgumentException ( @"Beatmap must be provided if frames are not already legacy frames." , nameof ( beatmap ) ) ;
2022-02-28 15:37:14 +08:00
2022-03-04 06:09:56 +08:00
if ( ! score . ScoreInfo . Ruleset . IsLegacyRuleset ( ) )
2022-02-28 15:41:39 +08:00
throw new ArgumentException ( @"Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format." , nameof ( score ) ) ;
2020-03-24 11:06:24 +08:00
}
2022-12-13 15:15:14 +08:00
public void Encode ( Stream stream , bool leaveOpen = false )
2020-03-24 11:06:24 +08:00
{
2022-12-13 15:15:14 +08:00
using ( SerializationWriter sw = new SerializationWriter ( stream , leaveOpen ) )
2020-03-24 13:13:46 +08:00
{
2021-11-24 14:25:49 +08:00
sw . Write ( ( byte ) ( score . ScoreInfo . Ruleset . OnlineID ) ) ;
2020-03-24 13:13:46 +08:00
sw . Write ( LATEST_VERSION ) ;
2023-07-04 13:50:34 +08:00
sw . Write ( score . ScoreInfo . BeatmapInfo ! . MD5Hash ) ;
2021-12-13 16:37:27 +08:00
sw . Write ( score . ScoreInfo . User . Username ) ;
sw . Write ( FormattableString . Invariant ( $"lazer-{score.ScoreInfo.User.Username}-{score.ScoreInfo.Date}" ) . ComputeMD5Hash ( ) ) ;
2020-03-24 13:13:46 +08:00
sw . Write ( ( ushort ) ( score . ScoreInfo . GetCount300 ( ) ? ? 0 ) ) ;
sw . Write ( ( ushort ) ( score . ScoreInfo . GetCount100 ( ) ? ? 0 ) ) ;
sw . Write ( ( ushort ) ( score . ScoreInfo . GetCount50 ( ) ? ? 0 ) ) ;
sw . Write ( ( ushort ) ( score . ScoreInfo . GetCountGeki ( ) ? ? 0 ) ) ;
sw . Write ( ( ushort ) ( score . ScoreInfo . GetCountKatu ( ) ? ? 0 ) ) ;
sw . Write ( ( ushort ) ( score . ScoreInfo . GetCountMiss ( ) ? ? 0 ) ) ;
sw . Write ( ( int ) ( score . ScoreInfo . TotalScore ) ) ;
sw . Write ( ( ushort ) score . ScoreInfo . MaxCombo ) ;
2024-03-19 16:20:59 +08:00
sw . Write ( score . ScoreInfo . Combo = = score . ScoreInfo . GetMaximumAchievableCombo ( ) ) ;
2020-03-24 13:13:46 +08:00
sw . Write ( ( int ) score . ScoreInfo . Ruleset . CreateInstance ( ) . ConvertToLegacyMods ( score . ScoreInfo . Mods ) ) ;
2020-03-24 11:06:24 +08:00
2020-03-24 13:13:46 +08:00
sw . Write ( getHpGraphFormatted ( ) ) ;
sw . Write ( score . ScoreInfo . Date . DateTime ) ;
sw . WriteByteArray ( createReplayData ( ) ) ;
2023-09-01 14:10:26 +08:00
sw . Write ( score . ScoreInfo . LegacyOnlineID ) ;
2020-03-24 13:13:46 +08:00
writeModSpecificData ( score . ScoreInfo , sw ) ;
2022-12-06 19:10:51 +08:00
sw . WriteByteArray ( createScoreInfoData ( ) ) ;
2020-03-24 13:13:46 +08:00
}
2020-03-24 11:06:24 +08:00
}
2020-03-24 13:13:46 +08:00
private void writeModSpecificData ( ScoreInfo score , SerializationWriter sw )
2020-03-24 11:06:24 +08:00
{
}
2020-03-24 13:13:46 +08:00
2022-12-06 19:10:51 +08:00
private byte [ ] createReplayData ( ) = > compress ( replayStringContent ) ;
private byte [ ] createScoreInfoData ( ) = > compress ( LegacyReplaySoloScoreInfo . FromScore ( score . ScoreInfo ) . Serialize ( ) ) ;
private byte [ ] compress ( string data )
2020-03-24 13:13:46 +08:00
{
2022-12-06 19:10:51 +08:00
byte [ ] content = new ASCIIEncoding ( ) . GetBytes ( data ) ;
2020-03-24 13:13:46 +08:00
using ( var outStream = new MemoryStream ( ) )
{
using ( var lzma = new LzmaStream ( new LzmaEncoderProperties ( false , 1 < < 21 , 255 ) , false , outStream ) )
{
outStream . Write ( lzma . Properties ) ;
long fileSize = content . Length ;
for ( int i = 0 ; i < 8 ; i + + )
outStream . WriteByte ( ( byte ) ( fileSize > > ( 8 * i ) ) ) ;
lzma . Write ( content ) ;
}
return outStream . ToArray ( ) ;
}
}
private string replayStringContent
{
get
{
StringBuilder replayData = new StringBuilder ( ) ;
2022-03-24 14:49:13 +08:00
// As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing.
2022-03-24 15:43:41 +08:00
double offset = beatmap ? . BeatmapInfo . BeatmapVersion < 5 ? - LegacyBeatmapDecoder . EARLY_VERSION_TIMING_OFFSET : 0 ;
2022-03-24 14:49:13 +08:00
2023-06-23 23:59:36 +08:00
int lastTime = 0 ;
2021-04-28 20:55:20 +08:00
2023-06-24 21:35:07 +08:00
if ( score . Replay ! = null )
2023-06-23 23:59:36 +08:00
{
2023-06-24 21:35:07 +08:00
foreach ( var f in score . Replay . Frames )
{
var legacyFrame = getLegacyFrame ( f ) ;
// Rounding because stable could only parse integral values
int time = ( int ) Math . Round ( legacyFrame . Time + offset ) ;
replayData . Append ( FormattableString . Invariant ( $"{time - lastTime}|{legacyFrame.MouseX ?? 0}|{legacyFrame.MouseY ?? 0}|{(int)legacyFrame.ButtonState}," ) ) ;
lastTime = time ;
}
2020-03-24 13:13:46 +08:00
}
2021-12-05 00:02:39 +08:00
// Warning: this is purposefully hardcoded as a string rather than interpolating, as in some cultures the minus sign is not encoded as the standard ASCII U+00C2 codepoint,
// which then would break decoding.
replayData . Append ( @"-12345|0|0|0" ) ;
2020-03-24 13:13:46 +08:00
return replayData . ToString ( ) ;
}
}
2022-02-28 15:40:00 +08:00
private LegacyReplayFrame getLegacyFrame ( ReplayFrame replayFrame )
{
2022-02-28 15:41:39 +08:00
switch ( replayFrame )
{
case LegacyReplayFrame legacyFrame :
return legacyFrame ;
2022-02-28 15:40:00 +08:00
2022-02-28 15:41:39 +08:00
case IConvertibleReplayFrame convertibleFrame :
2022-07-03 19:27:56 +08:00
Debug . Assert ( beatmap ! = null ) ;
return convertibleFrame . ToLegacy ( beatmap ) ;
2022-02-28 15:41:39 +08:00
default :
throw new ArgumentException ( @"Frame could not be converted to legacy frames" , nameof ( replayFrame ) ) ;
}
2022-02-28 15:40:00 +08:00
}
2020-03-24 13:13:46 +08:00
private string getHpGraphFormatted ( )
{
// todo: implement, maybe?
return string . Empty ;
}
2020-03-24 11:06:24 +08:00
}
}