mirror of
https://github.com/ppy/osu
synced 2024-12-30 02:42:29 +00:00
7c9adc7ad3
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.
234 lines
8.4 KiB
C#
234 lines
8.4 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;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using osu.Game.Beatmaps;
|
|
using osu.Game.Rulesets.Mods;
|
|
using osu.Game.Rulesets.Objects;
|
|
using osu.Game.Rulesets.Objects.Legacy;
|
|
using osu.Game.Rulesets.Objects.Types;
|
|
using osu.Game.Rulesets.Osu.Mods;
|
|
using osu.Game.Rulesets.Osu.Objects;
|
|
using osu.Game.Rulesets.Osu.Scoring;
|
|
using osu.Game.Rulesets.Scoring;
|
|
using osu.Game.Rulesets.Scoring.Legacy;
|
|
|
|
namespace osu.Game.Rulesets.Osu.Difficulty
|
|
{
|
|
internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator
|
|
{
|
|
private readonly ScoreProcessor scoreProcessor = new OsuScoreProcessor();
|
|
|
|
private int legacyBonusScore;
|
|
private int standardisedBonusScore;
|
|
private int combo;
|
|
|
|
private double scoreMultiplier;
|
|
|
|
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
|
|
{
|
|
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
|
|
|
|
int countNormal = 0;
|
|
int countSlider = 0;
|
|
int countSpinner = 0;
|
|
|
|
foreach (HitObject obj in workingBeatmap.Beatmap.HitObjects)
|
|
{
|
|
switch (obj)
|
|
{
|
|
case IHasPath:
|
|
countSlider++;
|
|
break;
|
|
|
|
case IHasDuration:
|
|
countSpinner++;
|
|
break;
|
|
|
|
default:
|
|
countNormal++;
|
|
break;
|
|
}
|
|
}
|
|
|
|
int objectCount = countNormal + countSlider + countSpinner;
|
|
|
|
int drainLength = 0;
|
|
|
|
if (baseBeatmap.HitObjects.Count > 0)
|
|
{
|
|
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
|
|
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
|
|
}
|
|
|
|
scoreMultiplier = LegacyRulesetExtensions.CalculateDifficultyPeppyStars(baseBeatmap.Difficulty, objectCount, drainLength);
|
|
|
|
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
|
|
|
|
foreach (var obj in playableBeatmap.HitObjects)
|
|
simulateHit(obj, ref attributes);
|
|
|
|
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
|
|
attributes.BonusScore = legacyBonusScore;
|
|
attributes.MaxCombo = combo;
|
|
|
|
return attributes;
|
|
}
|
|
|
|
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
|
|
{
|
|
bool increaseCombo = true;
|
|
bool addScoreComboMultiplier = false;
|
|
|
|
bool isBonus = false;
|
|
HitResult bonusResult = HitResult.None;
|
|
|
|
int scoreIncrease = 0;
|
|
|
|
switch (hitObject)
|
|
{
|
|
case SliderHeadCircle:
|
|
case SliderTailCircle:
|
|
case SliderRepeat:
|
|
scoreIncrease = 30;
|
|
break;
|
|
|
|
case SliderTick:
|
|
scoreIncrease = 10;
|
|
break;
|
|
|
|
case SpinnerBonusTick:
|
|
scoreIncrease = 1100;
|
|
increaseCombo = false;
|
|
isBonus = true;
|
|
bonusResult = HitResult.LargeBonus;
|
|
break;
|
|
|
|
case SpinnerTick:
|
|
scoreIncrease = 100;
|
|
increaseCombo = false;
|
|
isBonus = true;
|
|
bonusResult = HitResult.SmallBonus;
|
|
break;
|
|
|
|
case HitCircle:
|
|
scoreIncrease = 300;
|
|
addScoreComboMultiplier = true;
|
|
break;
|
|
|
|
case Slider:
|
|
foreach (var nested in hitObject.NestedHitObjects)
|
|
simulateHit(nested, ref attributes);
|
|
|
|
scoreIncrease = 300;
|
|
increaseCombo = false;
|
|
addScoreComboMultiplier = true;
|
|
break;
|
|
|
|
case Spinner spinner:
|
|
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
|
|
// We'll redo the calculations to match osu-stable here...
|
|
const double maximum_rotations_per_second = 477.0 / 60;
|
|
|
|
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score.
|
|
// As we're primarily concerned with computing the maximum theoretical final score,
|
|
// this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1.
|
|
const double minimum_rotations_per_second = 3;
|
|
|
|
double secondsDuration = spinner.Duration / 1000;
|
|
|
|
// The total amount of half spins possible for the entire spinner.
|
|
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
|
|
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
|
|
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second);
|
|
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
|
|
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
|
|
|
|
for (int i = 0; i <= totalHalfSpinsPossible; i++)
|
|
{
|
|
if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0)
|
|
simulateHit(new SpinnerBonusTick(), ref attributes);
|
|
else if (i > 1 && i % 2 == 0)
|
|
simulateHit(new SpinnerTick(), ref attributes);
|
|
}
|
|
|
|
scoreIncrease = 300;
|
|
addScoreComboMultiplier = true;
|
|
break;
|
|
}
|
|
|
|
if (addScoreComboMultiplier)
|
|
{
|
|
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
|
|
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
|
|
}
|
|
|
|
if (isBonus)
|
|
{
|
|
legacyBonusScore += scoreIncrease;
|
|
standardisedBonusScore += scoreProcessor.GetBaseScoreForResult(bonusResult);
|
|
}
|
|
else
|
|
attributes.AccuracyScore += scoreIncrease;
|
|
|
|
if (increaseCombo)
|
|
combo++;
|
|
}
|
|
|
|
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
|
|
{
|
|
bool scoreV2 = mods.Any(m => m is ModScoreV2);
|
|
|
|
double multiplier = 1.0;
|
|
|
|
foreach (var mod in mods)
|
|
{
|
|
switch (mod)
|
|
{
|
|
case OsuModNoFail:
|
|
multiplier *= scoreV2 ? 1.0 : 0.5;
|
|
break;
|
|
|
|
case OsuModEasy:
|
|
multiplier *= 0.5;
|
|
break;
|
|
|
|
case OsuModHalfTime:
|
|
case OsuModDaycore:
|
|
multiplier *= 0.3;
|
|
break;
|
|
|
|
case OsuModHidden:
|
|
multiplier *= 1.06;
|
|
break;
|
|
|
|
case OsuModHardRock:
|
|
multiplier *= scoreV2 ? 1.10 : 1.06;
|
|
break;
|
|
|
|
case OsuModDoubleTime:
|
|
case OsuModNightcore:
|
|
multiplier *= scoreV2 ? 1.20 : 1.12;
|
|
break;
|
|
|
|
case OsuModFlashlight:
|
|
multiplier *= 1.12;
|
|
break;
|
|
|
|
case OsuModSpunOut:
|
|
multiplier *= 0.9;
|
|
break;
|
|
|
|
case OsuModRelax:
|
|
case OsuModAutopilot:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return multiplier;
|
|
}
|
|
}
|
|
}
|