1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-24 10:49:53 +08:00

Compare commits

...

230 Commits

402 changed files with 11894 additions and 3105 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
"rollForward": false "rollForward": false
}, },
"codefilesanity": { "codefilesanity": {
"version": "0.0.37", "version": "0.0.41",
"commands": [ "commands": [
"CodeFileSanity" "CodeFileSanity"
], ],
@@ -24,4 +24,4 @@
"rollForward": false "rollForward": false
} }
} }
} }
+4
View File
@@ -2,6 +2,10 @@
Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience.
## Foreword on AI usage
Our team believes in **human contributions**. Any contribution be it an issue report or a pull request which is created by, documented by, or aided by AI/LLM usage will typically be **closed and locked without further discussion**.
## Table of contents ## Table of contents
1. [Reporting bugs](#reporting-bugs) 1. [Reporting bugs](#reporting-bugs)
+1 -1
View File
@@ -136,7 +136,7 @@ When it comes to contributing to the project, the two main things you can do to
If you wish to help with localisation efforts, head over to [crowdin](https://crowdin.com/project/osu-web). If you wish to help with localisation efforts, head over to [crowdin](https://crowdin.com/project/osu-web).
We love to reward quality contributions. If you have made a large contribution, or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so. Our team believes in **human contributions**. Any contribution be it an issue report or a pull request which is created by, documented by, or aided by AI/LLM usage will typically be **closed and locked without further discussion**.
## Licence ## Licence
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.EmptyFreeform
{ {
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{ {
return new DifficultyAttributes(mods, 0); return new DifficultyAttributes(mods, 0);
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>(); protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>(); protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
} }
} }
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Pippidon
{ {
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{ {
return new DifficultyAttributes(mods, 0); return new DifficultyAttributes(mods, 0);
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>(); protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>(); protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
} }
} }
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.EmptyScrolling
{ {
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{ {
return new DifficultyAttributes(mods, 0); return new DifficultyAttributes(mods, 0);
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>(); protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>(); protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
} }
} }
@@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Pippidon
{ {
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{ {
return new DifficultyAttributes(mods, 0); return new DifficultyAttributes(mods, 0);
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty<DifficultyHitObject>(); protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty<Skill>(); protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty<Skill>();
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.318.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2026.513.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- Fody does not handle Android build well, and warns when unchanged.
+18 -7
View File
@@ -20,13 +20,24 @@ using Uri = Android.Net.Uri;
namespace osu.Android namespace osu.Android
{ {
[Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import beatmap", DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*",
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import skin", DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*",
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")] DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import replay", DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*",
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")] DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import beatmap", DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import skin", DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import replay", DataScheme = "content", DataMimeType = "application/x-osu-replay")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import file", DataScheme = "content", DataMimeTypes = new[]
{
"application/zip",
"application/octet-stream",
"application/download",
"application/x-zip",
"application/x-zip-compressed",
})]
[IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, Label = "Import", DataMimeTypes = new[]
{ {
"application/zip", "application/zip",
"application/octet-stream", "application/octet-stream",
+1 -1
View File
@@ -190,7 +190,7 @@ namespace osu.Desktop
} }
// user party // user party
if (!hideIdentifiableInformation && multiplayerClient.Room != null && multiplayerClient.Room.Settings.MatchType != MatchType.Matchmaking) if (!hideIdentifiableInformation && multiplayerClient.Room != null && !multiplayerClient.Room.Settings.MatchType.IsMatchmakingType())
{ {
MultiplayerRoom room = multiplayerClient.Room; MultiplayerRoom room = multiplayerClient.Room;
@@ -0,0 +1,13 @@
// 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 Newtonsoft.Json;
namespace osu.Desktop.IPC.Messages
{
public class HitCountMessage : OsuWebSocketMessage
{
[JsonProperty("new_hits")]
public long NewHits { get; init; }
}
}
@@ -0,0 +1,19 @@
// 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 Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions;
namespace osu.Desktop.IPC.Messages
{
public abstract class OsuWebSocketMessage
{
[JsonProperty("type")]
public string Type { get; }
protected OsuWebSocketMessage()
{
Type = GetType().ReadableName();
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// 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.Linq;
using System.Threading;
using osu.Desktop.IPC.Messages;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using JsonConvert = Newtonsoft.Json.JsonConvert;
namespace osu.Desktop.IPC
{
public partial class OsuWebSocketProvider : Component
{
private WebSocketServer? server;
private readonly Bindable<ScoreInfo> lastLocalScore = new Bindable<ScoreInfo>();
[BackgroundDependencyLoader]
private void load(SessionStatics sessionStatics)
{
server = new WebSocketServer(49727);
server.StartAsync().FireAndForget(onError: ex => Logger.Error(ex, "Failed to start websocket"));
sessionStatics.BindWith(Static.LastLocalUserScore, lastLocalScore);
}
protected override void LoadComplete()
{
base.LoadComplete();
lastLocalScore.BindValueChanged(val =>
{
if (val.NewValue == null)
return;
if (server?.IsRunning != true)
return;
var msg = new HitCountMessage { NewHits = val.NewValue.Statistics.Where(kv => kv.Key.IsBasic() && kv.Key.IsHit()).Sum(kv => kv.Value) };
broadcast(msg);
});
}
private void broadcast(OsuWebSocketMessage message)
{
if (server?.IsRunning != true)
return;
string messageString = JsonConvert.SerializeObject(message);
server.BroadcastAsync(messageString).FireAndForget();
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (server?.IsRunning == true)
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
server.StopAsync(cts.Token).WaitSafely();
server = null;
}
}
}
}
+6
View File
@@ -6,6 +6,7 @@ using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using Microsoft.Win32; using Microsoft.Win32;
using osu.Desktop.IPC;
using osu.Desktop.Performance; using osu.Desktop.Performance;
using osu.Desktop.Security; using osu.Desktop.Security;
using osu.Framework.Platform; using osu.Framework.Platform;
@@ -35,6 +36,8 @@ namespace osu.Desktop
public bool IsFirstRun { get; init; } public bool IsFirstRun { get; init; }
public bool EnableWebSocketServer { get; init; }
public OsuGameDesktop(string[]? args = null) public OsuGameDesktop(string[]? args = null)
: base(args) : base(args)
{ {
@@ -148,6 +151,9 @@ namespace osu.Desktop
osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this); archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this);
if (EnableWebSocketServer)
Add(new OsuWebSocketProvider());
} }
public override void SetHost(GameHost host) public override void SetHost(GameHost host)
+2 -1
View File
@@ -140,7 +140,8 @@ namespace osu.Desktop
{ {
host.Run(new OsuGameDesktop(args) host.Run(new OsuGameDesktop(args)
{ {
IsFirstRun = isFirstRun IsFirstRun = isFirstRun,
EnableWebSocketServer = Environment.GetEnvironmentVariable("OSU_WEBSOCKET_SERVER") == "1",
}); });
} }
} }
+2 -1
View File
@@ -25,7 +25,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="System.IO.Packaging" Version="10.0.5" /> <PackageReference Include="System.IO.Packaging" Version="10.0.5" />
<PackageReference Include="DiscordRichPresence" Version="1.6.1.70" /> <!-- Held back due to invite bug in newer versions. See https://github.com/Lachee/discord-rpc-csharp/issues/286-->
<PackageReference Include="DiscordRichPresence" Version="1.5.0.51" />
<PackageReference Include="Velopack" Version="0.0.1298" /> <PackageReference Include="Velopack" Version="0.0.1298" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Resources"> <ItemGroup Label="Resources">
@@ -14,11 +14,11 @@ namespace osu.Game.Rulesets.Catch.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Catch.Tests"; protected override string ResourceAssembly => "osu.Game.Rulesets.Catch.Tests";
[TestCase(4.0505463516206195d, 127, "diffcalc-test")] [TestCase(4.039861734717169d, 127, "diffcalc-test")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(5.1696411260785498d, 127, "diffcalc-test")] [TestCase(5.1527173897800873d, 127, "diffcalc-test")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime());
@@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Catch.Difficulty namespace osu.Game.Rulesets.Catch.Difficulty
{ {
@@ -22,8 +23,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{ {
private const double difficulty_multiplier = 4.59; private const double difficulty_multiplier = 4.59;
private float halfCatcherWidth;
public override int Version => 20251020; public override int Version => 20251020;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
@@ -31,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{ {
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{ {
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new CatchDifficultyAttributes { Mods = mods }; return new CatchDifficultyAttributes { Mods = mods };
@@ -46,12 +45,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return attributes; return attributes;
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
{ {
CatchHitObject? lastObject = null; CatchHitObject? lastObject = null;
List<DifficultyHitObject> objects = new List<DifficultyHitObject>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
// In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream.
foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects)) foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects))
{ {
@@ -68,16 +74,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty
return objects; return objects;
} }
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
{ {
halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
// For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay.
halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f);
return new Skill[] return new Skill[]
{ {
new Movement(mods, halfCatcherWidth, clockRate), new Movement(mods),
}; };
} }
@@ -11,12 +11,16 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
{ {
private const double direction_change_bonus = 21.0; private const double direction_change_bonus = 21.0;
public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier) public static double EvaluateDifficultyOf(DifficultyHitObject current)
{ {
var catchCurrent = (CatchDifficultyHitObject)current; var catchCurrent = (CatchDifficultyHitObject)current;
var catchLast = (CatchDifficultyHitObject)current.Previous(0); var catchLast = (CatchDifficultyHitObject)current.Previous(0);
var catchLastLast = (CatchDifficultyHitObject)current.Previous(1); var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);
// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
double catcherSpeedMultiplier = current.ClockRate;
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510);
@@ -40,6 +44,30 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
/ (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain; / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain;
} }
// Linear spacing nerf.
double linearSpacingCount = 0;
for (int i = 0; i < Math.Min(current.Index, 10); i++)
{
var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i);
// Only same direction movements matter as they do not take any additional inputs.
if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0)
break;
double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime);
double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime);
double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1);
if (relativeDifference > 0.05)
break;
linearSpacingCount++;
}
distanceAddition *= Math.Pow(0.7, linearSpacingCount);
// Bonus for edge dashes. // Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{ {
@@ -17,28 +17,14 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected override int SectionLength => 750; protected override int SectionLength => 750;
protected readonly float HalfCatcherWidth; public Movement(Mod[] mods)
/// <summary>
/// The speed multiplier applied to the player's catcher.
/// </summary>
private readonly double catcherSpeedMultiplier;
public Movement(Mod[] mods, float halfCatcherWidth, double clockRate)
: base(mods) : base(mods)
{ {
HalfCatcherWidth = halfCatcherWidth;
// In catch, clockrate adjustments do not only affect the timings of hitobjects,
// but also the speed of the player's catcher, which has an impact on difficulty
// TODO: Support variable clockrates caused by mods such as ModTimeRamp
// (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing)
catcherSpeedMultiplier = clockRate;
} }
protected override double StrainValueOf(DifficultyHitObject current) protected override double StrainValueOf(DifficultyHitObject current)
{ {
return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier); return MovementEvaluator.EvaluateDifficultyOf(current);
} }
} }
} }
@@ -18,6 +18,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
private readonly Path drawablePath; private readonly Path drawablePath;
private readonly List<(double Time, float X)> vertices = new List<(double, float)>(); private readonly List<(double Time, float X)> vertices = new List<(double, float)>();
private readonly List<Vector2> sliderVertices = new List<Vector2>();
public ScrollingPath() public ScrollingPath()
{ {
@@ -47,9 +48,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
private void computeTimeXs(JuiceStream hitObject) private void computeTimeXs(JuiceStream hitObject)
{ {
vertices.Clear(); vertices.Clear();
sliderVertices.Clear();
var sliderVertices = new List<Vector2>(); sliderVertices.AddRange(hitObject.Path.CalculatedPath);
hitObject.Path.GetPathToProgress(sliderVertices, 0, 1);
if (sliderVertices.Count == 0) if (sliderVertices.Count == 0)
return; return;
@@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Catch.Edit
{ {
private readonly List<ICheck> checks = new List<ICheck> private readonly List<ICheck> checks = new List<ICheck>
{ {
// Audio
new CheckCatchFewHitsounds(),
// Compose // Compose
new CheckBananaShowerGap(), new CheckBananaShowerGap(),
new CheckConcurrentObjects(), new CheckConcurrentObjects(),
@@ -0,0 +1,14 @@
// 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 osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Catch.Objects;
namespace osu.Game.Rulesets.Catch.Edit.Checks
{
public class CheckCatchFewHitsounds : CheckFewHitsounds
{
protected override bool IsExcludedFromHitsounding(HitObject hitObject) => hitObject is BananaShower;
}
}
@@ -117,6 +117,15 @@ namespace osu.Game.Rulesets.Catch.Edit.Setup
Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value; Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value;
Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value;
Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value; Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value;
// in lazer catch, Overall Difficulty does *nothing* - as it should be in a sane world.
// in stable, it does *one extremely specific thing* which is influence the infamous `difficultyPeppyStars`
// which in turn affects score V1 (see `LegacyRulesetExtensions.CalculateDifficultyPeppyStars()`).
// there is a Ranking Criteria rule saying that Overall Difficulty and Approach Rate should match:
// https://osu.ppy.sh/wiki/en/Ranking_criteria/osu!catch
// the one case wherein that breaks stable is on some marathon maps;
// on those setting Overall Difficulty too high can lead to score V1 exceeding 32 bits ("score overflow").
// that case can be manually handled by mappers.
Beatmap.Difficulty.OverallDifficulty = approachRateSlider.Current.Value;
Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value;
Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value;
@@ -175,8 +175,7 @@ namespace osu.Game.Rulesets.Catch.Objects
/// </remarks> /// </remarks>
public void ConvertFromSliderPath(SliderPath sliderPath, double velocity) public void ConvertFromSliderPath(SliderPath sliderPath, double velocity)
{ {
var sliderPathVertices = new List<Vector2>(); var sliderPathVertices = sliderPath.CalculatedPath;
sliderPath.GetPathToProgress(sliderPathVertices, 0, 1);
double time = 0; double time = 0;
@@ -19,6 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" />
<ProjectReference Include="..\osu.Game.Tests\osu.Game.Tests.csproj" />
<ProjectReference Include="..\osu.Game\osu.Game.csproj" /> <ProjectReference Include="..\osu.Game\osu.Game.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -13,6 +13,7 @@
</Compile> </Compile>
</ItemGroup> </ItemGroup>
<ItemGroup Label="Project References"> <ItemGroup Label="Project References">
<ProjectReference Include="..\osu.Game.Tests\osu.Game.Tests.csproj" />
<ProjectReference Include="..\osu.Game\osu.Game.csproj" /> <ProjectReference Include="..\osu.Game\osu.Game.csproj" />
<ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" />
</ItemGroup> </ItemGroup>
@@ -20,10 +20,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test] [Test]
public void TestKeyCountChange() public void TestKeyCountChange()
{ {
FormSliderBar<float> keyCount = null!; FormSliderBar<int> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4)); AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<float>>().First(), () => Is.Not.Null); AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<int>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5)); AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8", () => AddStep("change key count to 8", () =>
{ {
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
}); });
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>); AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("refuse", () => InputManager.Key(Key.Number2)); AddStep("refuse", () => InputManager.Key(Key.Number2));
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5)); AddUntilStep("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8 again", () => AddStep("change key count to 8 again", () =>
{ {
@@ -41,5 +41,32 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddStep("acquiesce", () => InputManager.Key(Key.Number1)); AddStep("acquiesce", () => InputManager.Key(Key.Number1));
AddUntilStep("beatmap became 8K", () => Game.Beatmap.Value.BeatmapInfo.Difficulty.CircleSize, () => Is.EqualTo(8)); AddUntilStep("beatmap became 8K", () => Game.Beatmap.Value.BeatmapInfo.Difficulty.CircleSize, () => Is.EqualTo(8));
} }
[Test]
public void TestDualStagesChange()
{
FormCheckBox dualStages = null!;
FormSliderBar<int> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve dual stages checkbox", () => dualStages = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormCheckBox>().First(), () => Is.Not.Null);
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<int>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("set dual stages", () =>
{
dualStages.Current.Value = true;
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("refuse", () => InputManager.Key(Key.Number2));
AddUntilStep("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("set dual stages again", () =>
{
dualStages.Current.Value = true;
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().Single().CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("acquiesce", () => InputManager.Key(Key.Number1));
AddUntilStep("beatmap became 12K", () => Game.Beatmap.Value.BeatmapInfo.Difficulty.CircleSize, () => Is.EqualTo(12));
}
} }
} }
@@ -57,6 +57,28 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddAssert("time is unchanged", () => EditorClock.CurrentTime, () => Is.EqualTo(initialTime)); AddAssert("time is unchanged", () => EditorClock.CurrentTime, () => Is.EqualTo(initialTime));
} }
[Test]
public void TestNoTwoObjectsAtSameTimeAndColumn()
{
AddStep("change seek setting to false", () => config.SetValue(OsuSetting.EditorAutoSeekOnPlacement, false));
AddStep("clear beatmap", () => EditorBeatmap.Clear());
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to centre of last column", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last().ScreenSpaceDrawQuad.Centre));
AddStep("place note", () => InputManager.Click(MouseButton.Left));
AddAssert("beatmap has 1 object", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(1));
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to centre of first column", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().First().ScreenSpaceDrawQuad.Centre));
AddStep("place note", () => InputManager.Click(MouseButton.Left));
AddAssert("beatmap has 2 objects", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(2));
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to centre of last column", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last().ScreenSpaceDrawQuad.Centre));
AddStep("place note", () => InputManager.Click(MouseButton.Left));
AddAssert("beatmap has 2 objects", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(2));
}
private void placeObject() private void placeObject()
{ {
AddStep("select note placement tool", () => InputManager.Key(Key.Number2)); AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
@@ -27,6 +27,24 @@ namespace osu.Game.Rulesets.Mania.Tests
[TestCase("100374")] [TestCase("100374")]
[TestCase("1450162")] [TestCase("1450162")]
[TestCase("4869637")] [TestCase("4869637")]
[TestCase("1K")]
[TestCase("2K")]
[TestCase("3K")]
[TestCase("4K")]
[TestCase("5K")]
[TestCase("6K")]
[TestCase("7K")]
[TestCase("8K")]
[TestCase("9K")]
[TestCase("10K")]
// [TestCase("11K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("12K")]
// [TestCase("13K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("14K")]
// [TestCase("15K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("16K")]
// [TestCase("17K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("18K")]
public void Test(string name) => base.Test(name); public void Test(string name) => base.Test(name);
protected override IEnumerable<ConvertValue> CreateConvertValue(HitObject hitObject) protected override IEnumerable<ConvertValue> CreateConvertValue(HitObject hitObject)
@@ -0,0 +1,45 @@
// 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 NUnit.Framework;
using osu.Framework.IO.Stores;
using static osu.Game.Tests.Beatmaps.Formats.LegacyBeatmapEncoderTest;
namespace osu.Game.Rulesets.Mania.Tests
{
[TestFixture]
public class ManiaLegacyBeatmapEncoderTest
{
private static readonly DllResourceStore beatmaps_resource_store = new DllResourceStore(typeof(ManiaLegacyBeatmapEncoderTest).Assembly);
[TestCase("1K")]
[TestCase("2K")]
[TestCase("3K")]
[TestCase("4K")]
[TestCase("5K")]
[TestCase("6K")]
[TestCase("7K")]
[TestCase("8K")]
[TestCase("9K")]
[TestCase("10K")]
// [TestCase("11K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("12K")]
// [TestCase("13K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("14K")]
// [TestCase("15K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("16K")]
// [TestCase("17K")] <- See comment in `ManiaBeatmapConverter` ctor for disable reason.
[TestCase("18K")]
[TestCase("7K+1")]
public void TestEncodeDecodeStability(string name)
{
var decoded = DecodeFromLegacy(beatmaps_resource_store.GetStream($"Resources/Testing/Beatmaps/{name}.osu"), beatmaps_resource_store, name);
var decodedAfterEncode = DecodeFromLegacy(EncodeToLegacy(decoded), beatmaps_resource_store, name);
Sort(decoded.beatmap);
Sort(decodedAfterEncode.beatmap);
CompareBeatmaps(decoded, decodedAfterEncode);
}
}
}
@@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
{ {
public partial class TestSceneManiaModNoRelease : RateAdjustedBeatmapTestScene public partial class TestSceneManiaModNoRelease : RateAdjustedBeatmapTestScene
{ {
protected override Ruleset CreateRuleset() => new ManiaRuleset();
private const double time_before_head = 250; private const double time_before_head = 250;
private const double time_head = 1500; private const double time_head = 1500;
private const double time_during_hold_1 = 2500; private const double time_during_hold_1 = 2500;
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":511,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]}]}
@@ -0,0 +1,46 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:10K
[Difficulty]
HPDrainRate:5
CircleSize:10
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
25,192,0,1,0,0:0:0:0:
76,192,125,1,0,0:0:0:0:
128,192,250,1,0,0:0:0:0:
179,192,375,1,0,0:0:0:0:
230,192,500,1,0,0:0:0:0:
281,192,625,1,0,0:0:0:0:
332,192,750,1,0,0:0:0:0:
384,192,875,1,0,0:0:0:0:
435,192,1000,1,0,0:0:0:0:
486,192,1125,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":531,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]}]}
@@ -0,0 +1,47 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:11K
[Difficulty]
HPDrainRate:5
CircleSize:11
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
23,192,0,1,0,0:0:0:0:
69,192,125,1,0,0:0:0:0:
116,192,250,1,0,0:0:0:0:
162,192,375,1,0,0:0:0:0:
209,192,500,1,0,0:0:0:0:
256,192,625,1,0,0:0:0:0:
302,192,750,1,0,0:0:0:0:
349,192,875,1,0,0:0:0:0:
395,192,1000,1,0,0:0:0:0:
442,192,1125,1,0,0:0:0:0:
488,192,1250,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":551,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]}]}
@@ -0,0 +1,48 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:12K
[Difficulty]
HPDrainRate:5
CircleSize:12
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
21,192,0,1,0,0:0:0:0:
64,192,125,1,0,0:0:0:0:
106,192,250,1,0,0:0:0:0:
149,192,375,1,0,0:0:0:0:
192,192,500,1,0,0:0:0:0:
234,192,625,1,0,0:0:0:0:
277,192,750,1,0,0:0:0:0:
320,192,875,1,0,0:0:0:0:
362,192,1000,1,0,0:0:0:0:
405,192,1125,1,0,0:0:0:0:
448,192,1250,1,0,0:0:0:0:
490,192,1375,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]},{"RandomW":273326509,"RandomX":571,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"Column":12}]}]}
@@ -0,0 +1,49 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:13K
[Difficulty]
HPDrainRate:5
CircleSize:13
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
19,192,0,1,0,0:0:0:0:
59,192,125,1,0,0:0:0:0:
98,192,250,1,0,0:0:0:0:
137,192,375,1,0,0:0:0:0:
177,192,500,1,0,0:0:0:0:
216,192,625,1,0,0:0:0:0:
256,192,750,1,0,0:0:0:0:
295,192,875,1,0,0:0:0:0:
334,192,1000,1,0,0:0:0:0:
374,192,1125,1,0,0:0:0:0:
413,192,1250,1,0,0:0:0:0:
452,192,1375,1,0,0:0:0:0:
492,192,1500,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"Column":12}]},{"RandomW":273326509,"RandomX":591,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1625.0,"Objects":[{"StartTime":1625.0,"EndTime":1625.0,"Column":13}]}]}
@@ -0,0 +1,50 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:14K
[Difficulty]
HPDrainRate:5
CircleSize:14
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
18,192,0,1,0,0:0:0:0:
54,192,125,1,0,0:0:0:0:
91,192,250,1,0,0:0:0:0:
128,192,375,1,0,0:0:0:0:
164,192,500,1,0,0:0:0:0:
201,192,625,1,0,0:0:0:0:
237,192,750,1,0,0:0:0:0:
274,192,875,1,0,0:0:0:0:
310,192,1000,1,0,0:0:0:0:
347,192,1125,1,0,0:0:0:0:
384,192,1250,1,0,0:0:0:0:
420,192,1375,1,0,0:0:0:0:
457,192,1500,1,0,0:0:0:0:
493,192,1625,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"Column":12}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1625.0,"Objects":[{"StartTime":1625.0,"EndTime":1625.0,"Column":13}]},{"RandomW":273326509,"RandomX":611,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1750.0,"Objects":[{"StartTime":1750.0,"EndTime":1750.0,"Column":14}]}]}
@@ -0,0 +1,51 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:15K
[Difficulty]
HPDrainRate:5
CircleSize:15
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
17,192,0,1,0,0:0:0:0:
51,192,125,1,0,0:0:0:0:
85,192,250,1,0,0:0:0:0:
119,192,375,1,0,0:0:0:0:
153,192,500,1,0,0:0:0:0:
187,192,625,1,0,0:0:0:0:
221,192,750,1,0,0:0:0:0:
256,192,875,1,0,0:0:0:0:
290,192,1000,1,0,0:0:0:0:
324,192,1125,1,0,0:0:0:0:
358,192,1250,1,0,0:0:0:0:
392,192,1375,1,0,0:0:0:0:
426,192,1500,1,0,0:0:0:0:
460,192,1625,1,0,0:0:0:0:
494,192,1750,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"Column":12}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1625.0,"Objects":[{"StartTime":1625.0,"EndTime":1625.0,"Column":13}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1750.0,"Objects":[{"StartTime":1750.0,"EndTime":1750.0,"Column":14}]},{"RandomW":273326509,"RandomX":631,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1875.0,"Objects":[{"StartTime":1875.0,"EndTime":1875.0,"Column":15}]}]}
@@ -0,0 +1,52 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:16K
[Difficulty]
HPDrainRate:5
CircleSize:16
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
16,192,0,1,0,0:0:0:0:
48,192,125,1,0,0:0:0:0:
80,192,250,1,0,0:0:0:0:
112,192,375,1,0,0:0:0:0:
144,192,500,1,0,0:0:0:0:
176,192,625,1,0,0:0:0:0:
208,192,750,1,0,0:0:0:0:
240,192,875,1,0,0:0:0:0:
272,192,1000,1,0,0:0:0:0:
304,192,1125,1,0,0:0:0:0:
336,192,1250,1,0,0:0:0:0:
368,192,1375,1,0,0:0:0:0:
400,192,1500,1,0,0:0:0:0:
432,192,1625,1,0,0:0:0:0:
464,192,1750,1,0,0:0:0:0:
496,192,1875,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"Column":12}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1625.0,"Objects":[{"StartTime":1625.0,"EndTime":1625.0,"Column":13}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1750.0,"Objects":[{"StartTime":1750.0,"EndTime":1750.0,"Column":14}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1875.0,"Objects":[{"StartTime":1875.0,"EndTime":1875.0,"Column":15}]},{"RandomW":273326509,"RandomX":651,"RandomY":842502087,"RandomZ":3579807591,"StartTime":2000.0,"Objects":[{"StartTime":2000.0,"EndTime":2000.0,"Column":16}]}]}
@@ -0,0 +1,53 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:17K
[Difficulty]
HPDrainRate:5
CircleSize:17
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
15,192,0,1,0,0:0:0:0:
45,192,125,1,0,0:0:0:0:
75,192,250,1,0,0:0:0:0:
105,192,375,1,0,0:0:0:0:
135,192,500,1,0,0:0:0:0:
165,192,625,1,0,0:0:0:0:
195,192,750,1,0,0:0:0:0:
225,192,875,1,0,0:0:0:0:
256,192,1000,1,0,0:0:0:0:
286,192,1125,1,0,0:0:0:0:
316,192,1250,1,0,0:0:0:0:
346,192,1375,1,0,0:0:0:0:
376,192,1500,1,0,0:0:0:0:
406,192,1625,1,0,0:0:0:0:
436,192,1750,1,0,0:0:0:0:
466,192,1875,1,0,0:0:0:0:
496,192,2000,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1125.0,"Objects":[{"StartTime":1125.0,"EndTime":1125.0,"Column":9}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1250.0,"Objects":[{"StartTime":1250.0,"EndTime":1250.0,"Column":10}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1375.0,"Objects":[{"StartTime":1375.0,"EndTime":1375.0,"Column":11}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"Column":12}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1625.0,"Objects":[{"StartTime":1625.0,"EndTime":1625.0,"Column":13}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1750.0,"Objects":[{"StartTime":1750.0,"EndTime":1750.0,"Column":14}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1875.0,"Objects":[{"StartTime":1875.0,"EndTime":1875.0,"Column":15}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":2000.0,"Objects":[{"StartTime":2000.0,"EndTime":2000.0,"Column":16}]},{"RandomW":273326509,"RandomX":671,"RandomY":842502087,"RandomZ":3579807591,"StartTime":2125.0,"Objects":[{"StartTime":2125.0,"EndTime":2125.0,"Column":17}]}]}
@@ -0,0 +1,54 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:18K
[Difficulty]
HPDrainRate:5
CircleSize:18
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
14,192,0,1,0,0:0:0:0:
42,192,125,1,0,0:0:0:0:
71,192,250,1,0,0:0:0:0:
99,192,375,1,0,0:0:0:0:
128,192,500,1,0,0:0:0:0:
156,192,625,1,0,0:0:0:0:
184,192,750,1,0,0:0:0:0:
213,192,875,1,0,0:0:0:0:
241,192,1000,1,0,0:0:0:0:
270,192,1125,1,0,0:0:0:0:
298,192,1250,1,0,0:0:0:0:
327,192,1375,1,0,0:0:0:0:
355,192,1500,1,0,0:0:0:0:
384,192,1625,1,0,0:0:0:0:
412,192,1750,1,0,0:0:0:0:
440,192,1875,1,0,0:0:0:0:
469,192,2000,1,0,0:0:0:0:
497,192,2125,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":331,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]}]}
@@ -0,0 +1,37 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:1K
[Difficulty]
HPDrainRate:5
CircleSize:1
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
256,192,0,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":351,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":351,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]}]}
@@ -0,0 +1,38 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:2K
[Difficulty]
HPDrainRate:5
CircleSize:2
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
128,192,0,1,0,0:0:0:0:
384,192,125,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":371,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":371,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":371,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]}]}
@@ -0,0 +1,39 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:3K
[Difficulty]
HPDrainRate:5
CircleSize:3
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
85,192,0,1,0,0:0:0:0:
256,192,125,1,0,0:0:0:0:
426,192,250,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":391,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":391,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":391,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":391,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]}]}
@@ -0,0 +1,40 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:4K
[Difficulty]
HPDrainRate:5
CircleSize:4
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
64,192,0,1,0,0:0:0:0:
192,192,125,1,0,0:0:0:0:
320,192,250,1,0,0:0:0:0:
448,192,375,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":411,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":411,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":411,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":411,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":411,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]}]}
@@ -0,0 +1,41 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:5K
[Difficulty]
HPDrainRate:5
CircleSize:5
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
51,192,0,1,0,0:0:0:0:
153,192,125,1,0,0:0:0:0:
256,192,250,1,0,0:0:0:0:
358,192,375,1,0,0:0:0:0:
460,192,500,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":431,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":431,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":431,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":431,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":431,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":431,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]}]}
@@ -0,0 +1,42 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:6K
[Difficulty]
HPDrainRate:5
CircleSize:6
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
42,192,0,1,0,0:0:0:0:
128,192,125,1,0,0:0:0:0:
213,192,250,1,0,0:0:0:0:
298,192,375,1,0,0:0:0:0:
384,192,500,1,0,0:0:0:0:
469,192,625,1,0,0:0:0:0:
@@ -0,0 +1,45 @@
osu file format v14
[General]
Mode: 3
SpecialStyle:1
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:8K
[Difficulty]
HPDrainRate:5
CircleSize:8
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
32,192,0,1,0,0:0:0:0:
96,192,125,1,0,0:0:0:0:
160,192,250,1,0,0:0:0:0:
224,192,375,1,0,0:0:0:0:
288,192,500,1,0,0:0:0:0:
352,192,625,1,0,0:0:0:0:
416,192,750,1,0,0:0:0:0:
480,192,875,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":451,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]}]}
@@ -0,0 +1,43 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:7K
[Difficulty]
HPDrainRate:5
CircleSize:7
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
36,192,0,1,0,0:0:0:0:
109,192,125,1,0,0:0:0:0:
182,192,250,1,0,0:0:0:0:
256,192,375,1,0,0:0:0:0:
329,192,500,1,0,0:0:0:0:
402,192,625,1,0,0:0:0:0:
475,192,750,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":471,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]}]}
@@ -0,0 +1,44 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:8K
[Difficulty]
HPDrainRate:5
CircleSize:8
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
32,192,0,1,0,0:0:0:0:
96,192,125,1,0,0:0:0:0:
160,192,250,1,0,0:0:0:0:
224,192,375,1,0,0:0:0:0:
288,192,500,1,0,0:0:0:0:
352,192,625,1,0,0:0:0:0:
416,192,750,1,0,0:0:0:0:
480,192,875,1,0,0:0:0:0:
@@ -0,0 +1 @@
{"Mappings":[{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":0.0,"Objects":[{"StartTime":0.0,"EndTime":0.0,"Column":0}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":125.0,"Objects":[{"StartTime":125.0,"EndTime":125.0,"Column":1}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":250.0,"Objects":[{"StartTime":250.0,"EndTime":250.0,"Column":2}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":375.0,"Objects":[{"StartTime":375.0,"EndTime":375.0,"Column":3}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"Column":4}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":625.0,"Objects":[{"StartTime":625.0,"EndTime":625.0,"Column":5}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":750.0,"Objects":[{"StartTime":750.0,"EndTime":750.0,"Column":6}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":875.0,"Objects":[{"StartTime":875.0,"EndTime":875.0,"Column":7}]},{"RandomW":273326509,"RandomX":491,"RandomY":842502087,"RandomZ":3579807591,"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"Column":8}]}]}
@@ -0,0 +1,45 @@
osu file format v14
[General]
Mode: 3
[Metadata]
Title:keycount test
TitleUnicode:keycount test
Artist:mania
ArtistUnicode:mania
Creator:spaceman_atlas
Version:9K
[Difficulty]
HPDrainRate:5
CircleSize:9
OverallDifficulty:5
ApproachRate:5
SliderMultiplier:1.4
SliderTickRate:1
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Fail)
//Storyboard Layer 2 (Pass)
//Storyboard Layer 3 (Foreground)
//Storyboard Layer 4 (Overlay)
//Storyboard Sound Samples
[TimingPoints]
0,500,4,2,0,100,1,0
[HitObjects]
28,192,0,1,0,0:0:0:0:
85,192,125,1,0,0:0:0:0:
142,192,250,1,0,0:0:0:0:
199,192,375,1,0,0:0:0:0:
256,192,500,1,0,0:0:0:0:
312,192,625,1,0,0:0:0:0:
369,192,750,1,0,0:0:0:0:
426,192,875,1,0,0:0:0:0:
483,192,1000,1,0,0:0:0:0:
@@ -33,6 +33,8 @@ namespace osu.Game.Rulesets.Mania.Tests
/// </summary> /// </summary>
public partial class TestSceneHoldNoteInput : RateAdjustedBeatmapTestScene public partial class TestSceneHoldNoteInput : RateAdjustedBeatmapTestScene
{ {
protected override Ruleset CreateRuleset() => new ManiaRuleset();
private const double time_before_head = 250; private const double time_before_head = 250;
private const double time_head = 1500; private const double time_head = 1500;
private const double time_during_hold_1 = 2500; private const double time_during_hold_1 = 2500;
@@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Mania.Tests
{ {
public partial class TestSceneMaximumScore : RateAdjustedBeatmapTestScene public partial class TestSceneMaximumScore : RateAdjustedBeatmapTestScene
{ {
protected override Ruleset CreateRuleset() => new ManiaRuleset();
private ScoreAccessibleReplayPlayer currentPlayer = null!; private ScoreAccessibleReplayPlayer currentPlayer = null!;
private List<JudgementResult> judgementResults = new List<JudgementResult>(); private List<JudgementResult> judgementResults = new List<JudgementResult>();
@@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Mania.Tests
{ {
public partial class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene public partial class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene
{ {
protected override Ruleset CreateRuleset() => new ManiaRuleset();
[Test] [Test]
public void TestPreviousHitWindowDoesNotExtendPastNextObject() public void TestPreviousHitWindowDoesNotExtendPastNextObject()
{ {
@@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Mania.Tests
{ {
public partial class TestSceneReplayRewinding : RateAdjustedBeatmapTestScene public partial class TestSceneReplayRewinding : RateAdjustedBeatmapTestScene
{ {
protected override Ruleset CreateRuleset() => new ManiaRuleset();
private ReplayPlayer currentPlayer = null!; private ReplayPlayer currentPlayer = null!;
[Test] [Test]
@@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Mania.Tests
[TestFixture] [TestFixture]
public partial class TestSceneTimingBasedNoteColouring : OsuTestScene public partial class TestSceneTimingBasedNoteColouring : OsuTestScene
{ {
protected override Ruleset CreateRuleset() => new ManiaRuleset();
private Bindable<bool> configTimingBasedNoteColouring; private Bindable<bool> configTimingBasedNoteColouring;
private ManualClock clock; private ManualClock clock;
@@ -11,5 +11,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Project References"> <ItemGroup Label="Project References">
<ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" />
<ProjectReference Include="..\osu.Game.Tests\osu.Game.Tests.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -66,6 +66,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
if (IsForCurrentRuleset && TargetColumns > ManiaRuleset.MAX_STAGE_KEYS) if (IsForCurrentRuleset && TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{ {
// If support for odd key counts above 10 (11 / 13 / 15 / 17K) is ever desired, this code needs to go.
// For now, it's probably fine, as stable doesn't support them outside of manual .osu edits either.
TargetColumns /= 2; TargetColumns /= 2;
Dual = true; Dual = true;
} }
@@ -19,6 +19,7 @@ using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Difficulty namespace osu.Game.Rulesets.Mania.Difficulty
{ {
@@ -36,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{ {
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new ManiaDifficultyAttributes { Mods = mods }; return new ManiaDifficultyAttributes { Mods = mods };
@@ -62,11 +63,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty
return 1; return 1;
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
{ {
var sortedObjects = beatmap.HitObjects.ToArray(); var sortedObjects = beatmap.HitObjects.ToArray();
int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns;
double clockRate = ModUtils.CalculateRateWithMods(mods);
LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime)));
List<DifficultyHitObject> objects = new List<DifficultyHitObject>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
@@ -88,7 +91,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
// Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required.
protected override IEnumerable<DifficultyHitObject> SortObjects(IEnumerable<DifficultyHitObject> input) => input; protected override IEnumerable<DifficultyHitObject> SortObjects(IEnumerable<DifficultyHitObject> input) => input;
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[]
{ {
new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns) new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns)
}; };
@@ -7,6 +7,7 @@ using osu.Framework.Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
@@ -19,13 +20,22 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{ {
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
private FormSliderBar<float> keyCountSlider { get; set; } = null!; private FormSliderBar<int> keyCountSlider { get; set; } = null!;
private FormCheckBox dualStages { get; set; } = null!;
private FormCheckBox specialStyle { get; set; } = null!; private FormCheckBox specialStyle { get; set; } = null!;
private FormSliderBar<float> healthDrainSlider { get; set; } = null!; private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private FormSliderBar<float> overallDifficultySlider { get; set; } = null!; private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!; private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private FormSliderBar<double> tickRateSlider { get; set; } = null!; private FormSliderBar<double> tickRateSlider { get; set; } = null!;
private readonly BindableInt singleStageKeyCount = new BindableInt
{
Default = (int)BeatmapDifficulty.DEFAULT_DIFFICULTY,
Precision = 1,
};
private readonly BindableInt actualKeyCount = new BindableInt();
[Resolved] [Resolved]
private Editor? editor { get; set; } private Editor? editor { get; set; }
@@ -37,20 +47,19 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{ {
Children = new Drawable[] Children = new Drawable[]
{ {
keyCountSlider = new FormSliderBar<float> keyCountSlider = new FormSliderBar<int>
{ {
Caption = BeatmapsetsStrings.ShowStatsCsMania, Caption = BeatmapsetsStrings.ShowStatsCsMania,
HintText = "The number of columns in the beatmap", HintText = "The number of columns in the beatmap",
Current = new BindableFloat(Beatmap.Difficulty.CircleSize) Current = singleStageKeyCount,
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 1,
},
TransferValueOnCommit = true, TransferValueOnCommit = true,
TabbableContentContainer = this, TabbableContentContainer = this,
}, },
dualStages = new FormCheckBox
{
Caption = "Dual stages",
HintText = "Doubles the number of keys by adding a second stage."
},
specialStyle = new FormCheckBox specialStyle = new FormCheckBox
{ {
Caption = "Use special (N+1) style", Caption = "Use special (N+1) style",
@@ -117,16 +126,54 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
}, },
}; };
keyCountSlider.Current.BindValueChanged(updateKeyCount); setStateFromActualKeyCount((int)Beatmap.Difficulty.CircleSize);
keyCountSlider.Current.BindValueChanged(_ => calculateActualKeyCount());
dualStages.Current.BindValueChanged(_ =>
{
updateSingleStageKeyCountBounds();
calculateActualKeyCount();
});
actualKeyCount.BindValueChanged(updateKeyCount);
healthDrainSlider.Current.BindValueChanged(_ => updateValues()); healthDrainSlider.Current.BindValueChanged(_ => updateValues());
overallDifficultySlider.Current.BindValueChanged(_ => updateValues()); overallDifficultySlider.Current.BindValueChanged(_ => updateValues());
baseVelocitySlider.Current.BindValueChanged(_ => updateValues()); baseVelocitySlider.Current.BindValueChanged(_ => updateValues());
tickRateSlider.Current.BindValueChanged(_ => updateValues()); tickRateSlider.Current.BindValueChanged(_ => updateValues());
} }
private void updateSingleStageKeyCountBounds()
{
singleStageKeyCount.MinValue = dualStages.Current.Value ? ManiaRuleset.MAX_STAGE_KEYS / 2 + 1 : 1;
singleStageKeyCount.MaxValue = dualStages.Current.Value ? LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT / 2 : ManiaRuleset.MAX_STAGE_KEYS;
}
private void setStateFromActualKeyCount(int keyCount)
{
actualKeyCount.Value = keyCount;
if (keyCount > 10)
{
dualStages.Current.Value = true;
singleStageKeyCount.Value = keyCount / 2;
}
else
{
dualStages.Current.Value = false;
singleStageKeyCount.Value = keyCount;
}
updateSingleStageKeyCountBounds();
}
private void calculateActualKeyCount()
{
actualKeyCount.Value = keyCountSlider.Current.Value * (dualStages.Current.Value ? 2 : 1);
}
private bool updatingKeyCount; private bool updatingKeyCount;
private void updateKeyCount(ValueChangedEvent<float> keyCount) private void updateKeyCount(ValueChangedEvent<int> keyCount)
{ {
if (updatingKeyCount) return; if (updatingKeyCount) return;
@@ -143,7 +190,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
Schedule(() => Schedule(() =>
{ {
changeHandler!.RestoreState(-1); changeHandler!.RestoreState(-1);
Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value = keyCount.OldValue; Beatmap.Difficulty.CircleSize = keyCount.OldValue;
setStateFromActualKeyCount(keyCount.OldValue);
updatingKeyCount = false; updatingKeyCount = false;
}); });
} }
@@ -158,7 +206,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{ {
// for now, update these on commit rather than making BeatmapMetadata bindables. // for now, update these on commit rather than making BeatmapMetadata bindables.
// after switching database engines we can reconsider if switching to bindables is a good direction. // after switching database engines we can reconsider if switching to bindables is a good direction.
Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value; Beatmap.Difficulty.CircleSize = actualKeyCount.Value;
Beatmap.SpecialStyle = specialStyle.Current.Value; Beatmap.SpecialStyle = specialStyle.Current.Value;
Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value;
Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value;
@@ -98,9 +98,9 @@ namespace osu.Game.Rulesets.Mania
return false; return false;
} }
public bool FilterMayChangeFromMods(ValueChangedEvent<IReadOnlyList<Mod>> mods) public bool FilterMayChangeFromMods(FilterCriteria criteria, ValueChangedEvent<IReadOnlyList<Mod>> mods)
{ {
if (includedKeyCounts.Count != LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT) if (includedKeyCounts.Count != LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT || criteria.Group == GroupMode.Variant)
{ {
// Interpreting as the Mod type is required for equality comparison. // Interpreting as the Mod type is required for equality comparison.
HashSet<Mod> oldSet = mods.OldValue.OfType<ManiaKeyMod>().AsEnumerable<Mod>().ToHashSet(); HashSet<Mod> oldSet = mods.OldValue.OfType<ManiaKeyMod>().AsEnumerable<Mod>().ToHashSet();
+21
View File
@@ -327,6 +327,8 @@ namespace osu.Game.Rulesets.Mania
public override RulesetSettingsSubsection CreateSettings() => new ManiaSettingsSubsection(this); public override RulesetSettingsSubsection CreateSettings() => new ManiaSettingsSubsection(this);
public override LocalisableString VariantDescription => "Keys";
public override IEnumerable<int> AvailableVariants public override IEnumerable<int> AvailableVariants
{ {
get get
@@ -483,6 +485,22 @@ namespace osu.Game.Rulesets.Mania
}; };
} }
public override IEnumerable<RulesetBeatmapAttribute> GetBeatmapAttributesForRankedPlayCard(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
var attributes = GetBeatmapAttributesForDisplay(beatmapInfo, mods).ToList();
// Key count attribute isn't relevant to ranked play (it's decided by the pool).
attributes.RemoveAll(a => a.Acronym == "KC");
float holdNoteRatio = beatmapInfo.TotalObjectCount == 0 ? 0 : (float)beatmapInfo.EndTimeObjectCount / beatmapInfo.TotalObjectCount;
attributes.Insert(0, new RulesetBeatmapAttribute("Hold notes", @"HN", holdNoteRatio, holdNoteRatio, 1)
{
ValueFormat = "P0"
});
return attributes;
}
public override IRulesetFilterCriteria CreateRulesetFilterCriteria() public override IRulesetFilterCriteria CreateRulesetFilterCriteria()
{ {
return new ManiaFilterCriteria(); return new ManiaFilterCriteria();
@@ -498,6 +516,9 @@ namespace osu.Game.Rulesets.Mania
public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null) public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null)
=> ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods); => ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods);
public override int GetVariantForBeatmap(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null)
=> GetKeyCount(beatmapInfo, mods);
} }
public enum PlayfieldType public enum PlayfieldType
@@ -73,6 +73,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep("end slider placement", () => InputManager.Click(MouseButton.Right)); AddStep("end slider placement", () => InputManager.Click(MouseButton.Right));
AddStep("seek to slider end", () =>
{
var slider = (Slider)EditorBeatmap.HitObjects.Single();
EditorClock.Seek(slider.EndTime);
});
AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2)); AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2));
AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.205f, 0))); AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.205f, 0)));
@@ -15,25 +15,49 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.6232533278125061d, 239, "diffcalc-test")] [TestCase(6.5243170265483581d, 239, "diffcalc-test")]
[TestCase(1.5045783545699611d, 54, "zero-length-sliders")] [TestCase(1.3280410795791415d, 54, "zero-length-sliders")]
[TestCase(0.43333836671191595d, 4, "very-fast-slider")] [TestCase(0.40867325147697559d, 4, "very-fast-slider")]
[TestCase(0.13841532030395723d, 2, "nan-slider")] [TestCase(0.87058175794353554d, 6, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(9.6491691624112761d, 239, "diffcalc-test")] [TestCase(9.4677607900646308d, 239, "diffcalc-test")]
[TestCase(1.756936832498702d, 54, "zero-length-sliders")] [TestCase(1.6856612715618886d, 54, "zero-length-sliders")]
[TestCase(0.57771197086735004d, 4, "very-fast-slider")] [TestCase(0.53588473186572561d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.6232533278125061d, 239, "diffcalc-test")] [TestCase(6.5243170265483581d, 239, "diffcalc-test")]
[TestCase(1.5045783545699611d, 54, "zero-length-sliders")] [TestCase(1.3280410795791415d, 54, "zero-length-sliders")]
[TestCase(0.43333836671191595d, 4, "very-fast-slider")] [TestCase(0.40867325147697559d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
[TestCase(239, "diffcalc-test")]
[TestCase(54, "zero-length-sliders")]
[TestCase(4, "very-fast-slider")]
public void TestOffsetChanges(int expectedMaxCombo, string name)
{
const double offset_iterations = 400;
var beatmap = GetBeatmap(name);
var attributes = CreateDifficultyCalculator(beatmap).Calculate();
double expectedStarRating = attributes.StarRating;
for (int i = 0; i < offset_iterations; i++)
{
foreach (var beatmapHitObject in beatmap.Beatmap.HitObjects)
beatmapHitObject.StartTime++;
attributes = CreateDifficultyCalculator(beatmap).Calculate();
// Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences.
Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001));
Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo));
}
}
protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap);
protected override Ruleset CreateRuleset() => new OsuRuleset(); protected override Ruleset CreateRuleset() => new OsuRuleset();
@@ -1 +1 @@
{"Mappings":[{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336}]}]} {"Mappings":[{"StartTime":76911.0,"Objects":[{"StartTime":76911.0,"EndTime":76911.0,"X":283.402,"Y":275.402}]},{"StartTime":77053.0,"Objects":[{"StartTime":77053.0,"EndTime":77053.0,"X":287.0515,"Y":279.0515}]},{"StartTime":77196.0,"Objects":[{"StartTime":77196.0,"EndTime":77196.0,"X":290.701019,"Y":282.701019}]},{"StartTime":77339.0,"Objects":[{"StartTime":77339.0,"EndTime":77339.0,"X":294.3505,"Y":286.3505}]},{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336,"StackOffset":{"X":0.0,"Y":0.0}}]}]}
@@ -9,10 +9,15 @@ SliderMultiplier:2
SliderTickRate:1 SliderTickRate:1
[TimingPoints] [TimingPoints]
76911,285.7142857142857,4,1,0,100,1,8
77211,-100,4,3,50,70,0,0 77211,-100,4,3,50,70,0,0
77497,8.40402703648439,4,3,51,70,1,8 77497,8.40402703648439,4,3,51,70,1,8
77497,NaN,4,3,51,70,0,8 77497,NaN,4,3,51,70,0,8
77498,285.714285714286,4,3,51,70,1,0 77498,285.714285714286,4,3,51,70,1,0
[HitObjects] [HitObjects]
298,290,76911,5,0,1:0:0:0:
298,290,77053,1,0,1:0:0:0:
298,290,77196,1,0,1:0:0:0:
298,290,77339,1,0,1:0:0:0:
298,290,77497,6,0,B|234:298|192:279|192:279|180:299|180:299|205:311|238:318|238:318|230:347|217:371|217:371|137:370|80:340|80:340|65:259|73:143|102:68|102:68|149:49|199:34|199:34|213:54|213:54|267:38|324:40|324:40|332:18|332:18|385:20|435:27|435:27|480:93|517:204|521:286|521:286|474:329|396:350|396:350|377:329|363:302|363:302|393:287|415:271|415:271|398:254|398:254|362:282|299:290,1,1723.66345596313,10|0,1:0|3:0,3:0:0:0: 298,290,77497,6,0,B|234:298|192:279|192:279|180:299|180:299|205:311|238:318|238:318|230:347|217:371|217:371|137:370|80:340|80:340|65:259|73:143|102:68|102:68|149:49|199:34|199:34|213:54|213:54|267:38|324:40|324:40|332:18|332:18|385:20|435:27|435:27|480:93|517:204|521:286|521:286|474:329|396:350|396:350|377:329|363:302|363:302|393:287|415:271|415:271|398:254|398:254|362:282|299:290,1,1723.66345596313,10|0,1:0|3:0,3:0:0:0:
@@ -0,0 +1,42 @@
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class AgilityEvaluator
{
private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers
/// <summary>
/// Evaluates the difficulty of fast aiming
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
if (current.BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.LazyJumpDistance;
double distanceScaled = Math.Min(distance, distance_cap) / distance_cap;
double agilityDifficulty = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime;
agilityDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5);
agilityDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
return agilityDifficulty;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000));
}
}
@@ -0,0 +1,126 @@
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class FlowAimEvaluator
{
private const double velocity_change_multiplier = 0.52;
/// <summary>
/// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them.
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
// If the last object is a slider, then we extend the travel velocity through the slider into the current object.
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
}
double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
double flowDifficulty = currVelocity;
// Apply high circle size bonus to the base velocity.
// We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling
flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
// Rhythm changes are harder to flow
flowDifficulty *= 1 + Math.Min(0.25,
Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4));
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value);
double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0;
double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1);
// Low angular velocity flow (angles are consistent) is easier to follow than erratic flow
flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 270.0);
}
// If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement
double overlappedNotesWeight = 1;
if (current.Index > 2)
{
double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj);
double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj);
double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj);
overlappedNotesWeight = 1 - o1 * o2 * o3;
}
if (osuCurrObj.Angle != null)
{
// Acute angles are also hard to flow
flowDifficulty += currVelocity *
SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) *
overlappedNotesWeight;
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
if (withSliderTravelDistance)
{
currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
}
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime),
Math.Abs(prevVelocity - currVelocity));
flowDifficulty += overlapVelocityBuff *
distRatio *
overlappedNotesWeight *
velocity_change_multiplier;
}
if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance)
{
// Include slider velocity to make velocity more consistent with snap
flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
}
// Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that
flowDifficulty = Math.Pow(flowDifficulty, 1.45);
// Reduce difficulty for low spacing since spacing below radius is always to be flowed
return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
}
private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second)
{
var firstBase = (OsuHitObject)first.BaseObject;
var secondBase = (OsuHitObject)second.BaseObject;
double objectRadius = firstBase.Radius;
double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition);
return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1);
}
}
}
@@ -0,0 +1,220 @@
// 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 osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim
{
public static class SnapAimEvaluator
{
private const double wide_angle_multiplier = 9.67;
private const double acute_angle_multiplier = 2.41;
private const double slider_multiplier = 1.5;
private const double velocity_change_multiplier = 0.9;
private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation
private const double maximum_repetition_nerf = 0.15;
private const double maximum_vector_influence = 0.5;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>angle difficulty,</description></item>
/// <item><description>sharp velocity increases,</description></item>
/// <item><description>and slider difficulty.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
}
double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
double snapDifficulty = currVelocity; // Start difficulty with regular velocity.
// Penalize angle repetition.
snapDifficulty *= vectorAngleRepetition(osuCurrObj, osuLastObj);
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double velocityInfluence = Math.Min(currVelocity, prevVelocity);
double acuteAngleBonus = 0;
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
acuteAngleBonus = CalcAngleAcuteness(currAngle);
// Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2);
}
double wideAngleBonus = calcAngleWideness(currAngle);
// Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here
wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3)));
// Rescaling velocity for the wide angle bonus
const double wide_angle_time_scale = 1.45;
double wideAngleCurrVelocity = currDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale);
double wideAnglePrevVelocity = prevDistance / Math.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale);
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / Math.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale));
}
wideAngleBonus *= Math.Min(wideAngleCurrVelocity, wideAnglePrevVelocity);
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.55 * (1 - distance);
}
}
// Add in acute angle bonus or wide angle bonus, whichever is larger.
snapDifficulty += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
double wiggleBonus = velocityInfluence
* DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
snapDifficulty += wiggleBonus * wiggle_multiplier;
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
if (withSliderTravelDistance)
{
// We want to use just the object jump without slider velocity when awarding differences
currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
}
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
double velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
snapDifficulty += velocityChangeBonus * velocity_change_multiplier;
}
// Reward sliders based on velocity.
if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance)
{
double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
snapDifficulty += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier;
}
// Apply high circle size bonus
snapDifficulty *= osuCurrObj.SmallCircleBonus;
snapDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
return snapDifficulty;
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65)));
private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous)
{
if (current.Angle == null || previous.Angle == null)
return 1;
const double note_limit = 6;
double constantAngleCount = 0;
for (int index = 0; index < note_limit; index++)
{
var loopObj = (OsuDifficultyHitObject)current.Previous(index);
if (loopObj.IsNull())
break;
// Only consider vectors in the same jump section, stopping to change rhythm ruins momentum
if (Math.Max(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, loopObj.AdjustedDeltaTime))
break;
if (loopObj.NormalisedVectorAngle.IsNotNull() && current.NormalisedVectorAngle.IsNotNull())
{
double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - loopObj.NormalisedVectorAngle.Value);
// Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1.
// https://www.desmos.com/calculator/a8jesv5sv2
constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference));
}
}
double vectorRepetition = Math.Pow(Math.Min(0.5 / constantAngleCount, 1), 2);
double stackFactor = DifficultyCalculationUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER);
double currAngle = current.Angle.Value;
double lastAngle = previous.Angle.Value;
double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor));
double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted;
return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2);
}
private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -1,172 +0,0 @@
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class AimEvaluator
{
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.55;
private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02;
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>angle difficulty,</description></item>
/// <item><description>sharp velocity increases,</description></item>
/// <item><description>and slider difficulty.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
{
if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object
currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity.
}
// As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime;
if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime;
prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity);
}
double wideAngleBonus = 0;
double acuteAngleBonus = 0;
double sliderBonus = 0;
double velocityChangeBonus = 0;
double wiggleBonus = 0;
double aimStrain = currVelocity; // Start strain with regular velocity.
if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
// Penalize angle repetition.
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
}
wideAngleBonus = calcWideAngleBonus(currAngle);
// Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.35 * (1 - distance);
}
}
}
if (Math.Max(prevVelocity, currVelocity) != 0)
{
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime;
currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime;
// Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
}
if (osuLastObj.BaseObject is Slider)
{
// Reward sliders based on velocity.
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;
}
aimStrain += wiggleBonus * wiggle_multiplier;
aimStrain += velocityChangeBonus * velocity_change_multiplier;
// Add in acute angle bonus or wide angle bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply high circle size bonus
aimStrain *= osuCurrObj.SmallCircleBonus;
// Add in additional slider velocity bonus.
if (withSliderTravelDistance)
aimStrain += sliderBonus * slider_multiplier;
return aimStrain;
}
private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
@@ -2,8 +2,12 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
@@ -28,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and whether the hidden mod is enabled.</description></item> /// <item><description>and whether the hidden mod is enabled.</description></item>
/// </list> /// </list>
/// </summary> /// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
{ {
if (current.BaseObject is Spinner) if (current.BaseObject is Spinner)
return 0; return 0;
@@ -40,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double smallDistNerf = 1.0; double smallDistNerf = 1.0;
double cumulativeStrainTime = 0.0; double cumulativeStrainTime = 0.0;
double result = 0.0; double flashlightDifficulty = 0.0;
OsuDifficultyHitObject lastObj = osuCurrent; OsuDifficultyHitObject lastObj = osuCurrent;
@@ -66,9 +70,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0);
// Bonus based on how visible the object is. // Bonus based on how visible the object is.
double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType<OsuModHidden>().Any(m => !m.OnlyFadeApproachCircles.Value)));
result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; flashlightDifficulty += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime;
if (currentObj.Angle != null && osuCurrent.Angle != null) if (currentObj.Angle != null && osuCurrent.Angle != null)
{ {
@@ -81,14 +85,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
lastObj = currentObj; lastObj = currentObj;
} }
result = Math.Pow(smallDistNerf * result, 2.0); flashlightDifficulty = Math.Pow(smallDistNerf * flashlightDifficulty, 2.0);
// Additional bonus for Hidden due to there being no approach circles. // Additional bonus for Hidden due to there being no approach circles.
if (hidden) if (mods.OfType<OsuModHidden>().Any())
result *= 1.0 + hidden_bonus; flashlightDifficulty *= 1.0 + hidden_bonus;
// Nerf patterns with repeated angles. // Nerf patterns with repeated angles.
result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); flashlightDifficulty *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0);
double sliderBonus = 0.0; double sliderBonus = 0.0;
@@ -108,9 +112,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
sliderBonus /= (osuSlider.RepeatCount + 1); sliderBonus /= (osuSlider.RepeatCount + 1);
} }
result += sliderBonus * slider_multiplier; flashlightDifficulty += sliderBonus * slider_multiplier;
return result; return flashlightDifficulty;
} }
} }
} }
@@ -0,0 +1,272 @@
// 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 osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class ReadingEvaluator
{
private const double reading_window_size = 3000; // 3 seconds
private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers
private const double hidden_multiplier = 0.28;
private const double density_multiplier = 2.4;
private const double density_difficulty_base = 2.5;
private const double preempt_balancing_factor = 140000;
private const double preempt_starting_point = 500; // AR 9.66 in milliseconds
private const double minimum_angle_relevancy_time = 2000; // 2 seconds
private const double maximum_angle_relevancy_time = 200;
public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden)
{
if (current.BaseObject is Spinner || current.Index == 0)
return 0;
var currObj = (OsuDifficultyHitObject)current;
var nextObj = (OsuDifficultyHitObject)current.Next(0);
double velocity = Math.Max(1, currObj.LazyJumpDistance / currObj.AdjustedDeltaTime); // Only allow velocity to buff
double currentVisibleObjectDensity = retrieveCurrentVisibleObjectDensity(currObj);
double pastObjectDifficultyInfluence = getPastObjectDifficultyInfluence(currObj);
double constantAngleNerfFactor = getConstantAngleNerfFactor(currObj);
double noteDensityDifficulty = calculateDensityDifficulty(nextObj, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity);
double hiddenDifficulty = hidden
? calculateHiddenDifficulty(currObj, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor)
: 0;
double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt);
double readingDifficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty);
// Having less time to process information is harder
readingDifficulty *= highBpmBonus(currObj.AdjustedDeltaTime);
return readingDifficulty;
}
/// <summary>
/// Calculates the density difficulty of the current object and how hard it is to aim it because of it based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>density of objects visible when the current object appears,</description></item>
/// <item><description>density of objects visible when the current object needs to be clicked,</description></item>
/// /// </list>
/// </summary>
private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor,
double pastObjectDifficultyInfluence, double currentVisibleObjectDensity)
{
// Consider future densities too because it can make the path the cursor takes less clear
double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity);
if (nextObj != null)
{
// Reduce difficulty if movement to next object is small
futureObjectDifficultyInfluence *= DifficultyCalculationUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold);
}
// Value higher note densities exponentially
double noteDensityDifficulty = Math.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity;
// Award only denser than average maps.
noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base);
// Apply a soft cap to general density reading to account for partial memorization
noteDensityDifficulty = Math.Pow(noteDensityDifficulty, 0.45) * density_multiplier;
return noteDensityDifficulty;
}
/// <summary>
/// Calculates the difficulty of aiming the current object when the approach rate is very high based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>how many milliseconds elapse between the approach circle appearing and touching the inner circle</description></item>
/// </list>
/// </summary>
private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt)
{
// Arbitrary curve for the base value preempt difficulty should have as approach rate increases.
// https://www.desmos.com/calculator/c175335a71
double preemptDifficulty = Math.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor;
preemptDifficulty *= constantAngleNerfFactor * velocity;
return preemptDifficulty;
}
/// <summary>
/// Calculates the difficulty of aiming the current object when the hidden mod is active based on:
/// <list type="bullet">
/// <item><description>cursor velocity to the current object,</description></item>
/// <item><description>time the current object spends invisible,</description></item>
/// <item><description>density of objects visible when the current object appears,</description></item>
/// <item><description>density of objects visible when the current object needs to be clicked,</description></item>
/// <item><description>how many times the current object's angle was repeated,</description></item>
/// <item><description>if the current object is perfectly stacked to the previous one</description></item>
/// </list>
/// </summary>
private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity,
double constantAngleNerfFactor)
{
// Higher preempt means that time spent invisible is higher too, we want to reward that
double preemptFactor = Math.Pow(currObj.Preempt, 2.2) * 0.01;
// Account for both past and current densities
double densityFactor = Math.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3;
double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01;
// Apply a soft cap to general HD reading to account for partial memorization
hiddenDifficulty = Math.Pow(hiddenDifficulty, 0.4) * hidden_multiplier;
var previousObj = (OsuDifficultyHitObject)currObj.Previous(0);
// Buff perfect stacks only if current note is completely invisible at the time you click the previous note.
if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt)
hiddenDifficulty += hidden_multiplier * 2500 / Math.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes
return hiddenDifficulty;
}
private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject currObj)
{
double pastObjectDifficultyInfluence = 0;
foreach (var loopObj in retrievePastVisibleObjects(currObj))
{
double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false);
// When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly.
loopDifficulty *= DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold);
// Account less for objects close to the max reading window
double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime;
double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj);
loopDifficulty *= timeNerfFactor;
pastObjectDifficultyInfluence += loopDifficulty;
}
return pastObjectDifficultyInfluence;
}
// Returns a list of objects that are visible on screen at the point in time the current object becomes visible.
private static IEnumerable<OsuDifficultyHitObject> retrievePastVisibleObjects(OsuDifficultyHitObject current)
{
for (int i = 0; i < current.Index; i++)
{
OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i);
if (hitObject.IsNull() ||
current.StartTime - hitObject.StartTime > reading_window_size ||
hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked
break;
yield return hitObject;
}
}
// Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window.
private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject current)
{
double visibleObjectCount = 0;
OsuDifficultyHitObject? hitObject = (OsuDifficultyHitObject)current.Next(0);
while (hitObject != null)
{
if (hitObject.StartTime - current.StartTime > reading_window_size ||
current.StartTime < hitObject.StartTime - hitObject.Preempt) // Object not visible at the time current object needs to be clicked.
break;
double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime;
double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj);
visibleObjectCount += hitObject.OpacityAt(current.BaseObject.StartTime, false) * timeNerfFactor;
hitObject = (OsuDifficultyHitObject?)hitObject.Next(0);
}
return visibleObjectCount;
}
// Returns a factor of how often the current object's angle has been repeated in a certain time frame.
// It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity.
// https://www.desmos.com/calculator/eb057a4822
private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current)
{
double constantAngleCount = 0;
int index = 0;
double currentTimeGap = 0;
OsuDifficultyHitObject loopObjPrev0 = current;
OsuDifficultyHitObject? loopObjPrev1 = null;
OsuDifficultyHitObject? loopObjPrev2 = null;
while (currentTimeGap < minimum_angle_relevancy_time)
{
var loopObj = (OsuDifficultyHitObject)current.Previous(index);
if (loopObj.IsNull())
break;
// Account less for objects that are close to the time limit.
double longIntervalFactor = 1 - DifficultyCalculationUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time);
if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull())
{
double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value);
double angleDifferenceAlternating = Math.PI;
if (loopObjPrev0.Angle != null && loopObjPrev1?.Angle != null && loopObjPrev2?.Angle != null)
{
angleDifferenceAlternating = Math.Abs(loopObjPrev1.Angle.Value - loopObj.Angle.Value);
angleDifferenceAlternating += Math.Abs(loopObjPrev2.Angle.Value - loopObjPrev0.Angle.Value);
double weight = 1.0;
// Be sure that one of the angles is very sharp, when other is wide
weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5);
weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120);
// Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference
angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight);
}
double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor;
}
currentTimeGap = current.StartTime - loopObj.StartTime;
index++;
loopObjPrev2 = loopObjPrev1;
loopObjPrev1 = loopObjPrev0;
loopObjPrev0 = loopObj;
}
return Math.Clamp(2 / constantAngleCount, 0.2, 1);
}
// Returns a nerfing factor for when objects are very distant in time, affecting reading less.
private static double getTimeNerfFactor(double deltaTime)
{
return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1);
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000));
}
}
@@ -8,15 +8,16 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
{ {
public static class RhythmEvaluator public static class RhythmEvaluator
{ {
private const int history_time_max = 5 * 1000; // 5 seconds private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32; private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 1.0; private const double rhythm_overall_multiplier = 0.95;
private const double rhythm_ratio_multiplier = 15.0; private const double rhythm_ratio_multiplier = 26.0;
/// <summary> /// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>. /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@@ -26,11 +27,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner) if (current.BaseObject is Spinner)
return 0; return 0;
var currentOsuObject = (OsuDifficultyHitObject)current;
double rhythmComplexitySum = 0; double rhythmComplexitySum = 0;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindow(HitResult.Great) * 0.3;
var island = new Island(deltaDifferenceEpsilon); var island = new Island(deltaDifferenceEpsilon);
var previousIsland = new Island(deltaDifferenceEpsilon); var previousIsland = new Island(deltaDifferenceEpsilon);
@@ -57,6 +56,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
for (int i = rhythmStart; i > 0; i--) for (int i = rhythmStart; i > 0; i--)
{ {
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);
if (currObj.BaseObject is Spinner)
continue;
// scales note 0 to 1 from history to now // scales note 0 to 1 from history to now
double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
@@ -64,44 +65,56 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
// Use custom cap value to ensure that that at this point delta time is actually zero // Use custom cap value to ensure that at this point delta time is actually zero
double currDelta = Math.Max(currObj.DeltaTime, 1e-7); double currDelta = Math.Max(currObj.DeltaTime, 1e-7);
double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7);
double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7);
// Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change
if (island.Delta == int.MaxValue)
island = new Island((int)currDelta, deltaDifferenceEpsilon);
// calculate how much current delta difference deserves a rhythm bonus // calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta);
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
// reduce ratio bonus if delta difference is too big // reduce ratio bonus if delta difference is too big
double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0);
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = windowPenalty * currRatio * differenceMultiplier; double effectiveRatio = getEffectiveRatio(deltaDifference) * windowPenalty * differenceMultiplier;
// if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion
// while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple
// for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double
if (prevObj.BaseObject is Slider)
{
double sliderLazyEndDelta = currObj.MinimumJumpTime;
double sliderLazyDeltaDifference = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta);
double sliderRealEndDelta = currObj.LastObjectEndDeltaTime;
double sliderRealDeltaDifference = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta);
double sliderEffectiveRatio = Math.Min(getEffectiveRatio(sliderLazyDeltaDifference), getEffectiveRatio(sliderRealDeltaDifference));
effectiveRatio = Math.Min(sliderEffectiveRatio, effectiveRatio);
}
bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon;
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
// island is still progressing
island.AddDelta((int)currDelta);
}
if (firstDeltaSwitch) if (firstDeltaSwitch)
{ {
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) if (Math.Abs(prevDelta - currDelta) > deltaDifferenceEpsilon)
{
// island is still progressing
island.AddDelta((int)currDelta);
}
else
{ {
// bpm change is into slider, this is easy acc window // bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider) if (currObj.BaseObject is Slider)
effectiveRatio *= 0.125; effectiveRatio *= 0.5;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.3;
// repeated island polarity (2 -> 4, 3 -> 5) // repeated island polarity (2 -> 4, 3 -> 5)
if (island.IsSimilarPolarity(previousIsland)) if (island.IsSimilarPolarity(previousIsland))
@@ -116,6 +129,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (previousIsland.DeltaCount == island.DeltaCount) if (previousIsland.DeltaCount == island.DeltaCount)
effectiveRatio *= 0.5; effectiveRatio *= 0.5;
if (isSpeedingUp)
effectiveRatio *= 0.65;
var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));
if (islandCount != default) if (islandCount != default)
@@ -134,7 +150,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
} }
else else
{ {
islandCounts.Add((island, 1)); if (island.DeltaCount > 0)
{
islandCounts.Add((island, 1));
}
} }
// scale down the difficulty if the object is doubletappable // scale down the difficulty if the object is doubletappable
@@ -176,10 +195,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
prevObj = currObj; prevObj = currObj;
} }
double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) // If the current island is long we don't want the sum to have as big of an effect
rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0)); rhythmComplexitySum *= DifficultyCalculationUtils.ReverseLerp(island.DeltaCount, 22, 3);
return rhythmDifficulty; return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though);
}
private static double getEffectiveRatio(double deltaDifference)
{
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
return 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
} }
private class Island : IEquatable<Island> private class Island : IEquatable<Island>
@@ -211,9 +238,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public bool IsSimilarPolarity(Island other) public bool IsSimilarPolarity(Island other)
{ {
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) // single delta islands shouldn't be compared
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation if (DeltaCount <= 1 || other.DeltaCount <= 1)
return DeltaCount % 2 == other.DeltaCount % 2; return false;
return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
DeltaCount % 2 == other.DeltaCount % 2;
} }
public bool Equals(Island? other) public bool Equals(Island? other)
@@ -2,47 +2,39 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed
{ {
public static class SpeedEvaluator public static class SpeedEvaluator
{ {
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40; private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.8;
/// <summary> /// <summary>
/// Evaluates the difficulty of tapping the current object, based on: /// Evaluates the difficulty of tapping the current object, based on:
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>time between pressing the previous and current object,</description></item> /// <item><description>time between pressing the previous and current object,</description></item>
/// <item><description>distance between those objects,</description></item>
/// <item><description>and how easily they can be cheesed.</description></item> /// <item><description>and how easily they can be cheesed.</description></item>
/// </list> /// </list>
/// </summary> /// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods) public static double EvaluateDifficultyOf(DifficultyHitObject current)
{ {
if (current.BaseObject is Spinner) if (current.BaseObject is Spinner)
return 0; return 0;
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current; var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double strainTime = osuCurrObj.AdjustedDeltaTime; double strainTime = osuCurrObj.AdjustedDeltaTime;
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow. // Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindow(HitResult.Great)) / 0.93, 0.92, 1);
// speedBonus will be 0.0 for BPM < 200 // speedBonus will be 0.0 for BPM < 200
double speedBonus = 0.0; double speedBonus = 0.0;
@@ -51,26 +43,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus)
speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2);
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
// Cap distance at single_spacing_threshold
distance = Math.Min(distance, single_spacing_threshold);
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps
distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0;
// Base difficulty with all bonuses // Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; double speedDifficulty = (1 + speedBonus) * 1000 / strainTime;
speedDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
// Apply penalty if there's doubletappable doubles // Apply penalty if there's doubletappable doubles
return difficulty * doubletapness; return speedDifficulty * doubletapness;
} }
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, ms / 1000));
} }
} }
@@ -45,6 +45,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight_difficulty")] [JsonProperty("flashlight_difficulty")]
public double FlashlightDifficulty { get; set; } public double FlashlightDifficulty { get; set; }
/// <summary>
/// The difficulty corresponding to the reading skill.
/// </summary>
[JsonProperty("reading_difficulty")]
public double ReadingDifficulty { get; set; }
/// <summary> /// <summary>
/// Describes how much of <see cref="AimDifficulty"/> is contributed to by hitcircles or sliders. /// Describes how much of <see cref="AimDifficulty"/> is contributed to by hitcircles or sliders.
/// A value closer to 1.0 indicates most of <see cref="AimDifficulty"/> is contributed by hitcircles. /// A value closer to 1.0 indicates most of <see cref="AimDifficulty"/> is contributed by hitcircles.
@@ -75,6 +81,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("speed_difficult_strain_count")] [JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; } public double SpeedDifficultStrainCount { get; set; }
[JsonProperty("reading_difficult_note_count")]
public double ReadingDifficultNoteCount { get; set; }
[JsonProperty("nested_score_per_object")] [JsonProperty("nested_score_per_object")]
public double NestedScorePerObject { get; set; } public double NestedScorePerObject { get; set; }
@@ -84,11 +93,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("maximum_legacy_combo_score")] [JsonProperty("maximum_legacy_combo_score")]
public double MaximumLegacyComboScore { get; set; } public double MaximumLegacyComboScore { get; set; }
/// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary>
public double DrainRate { get; set; }
/// <summary> /// <summary>
/// The number of hitcircles in the beatmap. /// The number of hitcircles in the beatmap.
/// </summary> /// </summary>
@@ -111,6 +115,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_AIM, AimDifficulty); yield return (ATTRIB_ID_AIM, AimDifficulty);
yield return (ATTRIB_ID_SPEED, SpeedDifficulty); yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_READING, ReadingDifficulty);
yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightDifficulty()) if (ShouldSerializeFlashlightDifficulty())
@@ -127,6 +132,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject); yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject);
yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier);
yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore);
yield return (ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT, ReadingDifficultNoteCount);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -135,6 +141,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
AimDifficulty = values[ATTRIB_ID_AIM]; AimDifficulty = values[ATTRIB_ID_AIM];
SpeedDifficulty = values[ATTRIB_ID_SPEED]; SpeedDifficulty = values[ATTRIB_ID_SPEED];
ReadingDifficulty = values[ATTRIB_ID_READING];
StarRating = values[ATTRIB_ID_DIFFICULTY]; StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
@@ -147,7 +154,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT]; NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT];
LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER];
MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE];
DrainRate = onlineInfo.DrainRate; ReadingDifficultNoteCount = values[ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT];
HitCircleCount = onlineInfo.CircleCount; HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount; SliderCount = onlineInfo.SliderCount;
SpinnerCount = onlineInfo.SpinnerCount; SpinnerCount = onlineInfo.SpinnerCount;
@@ -8,21 +8,19 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Utils;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuDifficultyCalculator : DifficultyCalculator public class OsuDifficultyCalculator : DifficultyCalculator
{ {
private const double star_rating_multiplier = 0.0265;
public override int Version => 20251020; public override int Version => 20251020;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
@@ -30,23 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{ {
} }
public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills)
{
double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate;
return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
}
public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate)
{
HitWindows hitWindows = new OsuHitWindows();
hitWindows.SetDifficulty(overallDifficulty);
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return (79.5 - hitWindowGreat) / 6;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{ {
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new OsuDifficultyAttributes { Mods = mods }; return new OsuDifficultyAttributes { Mods = mods };
@@ -55,66 +37,61 @@ namespace osu.Game.Rulesets.Osu.Difficulty
var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders); var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders);
var speed = skills.OfType<Speed>().Single(); var speed = skills.OfType<Speed>().Single();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault(); var flashlight = skills.OfType<Flashlight>().SingleOrDefault();
var reading = skills.OfType<Reading>().Single();
double aimDifficultyValue = aim.DifficultyValue();
double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
double speedDifficultyValue = speed.DifficultyValue();
double readingDifficultyValue = reading.DifficultyValue();
double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue);
double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue);
double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue);
double speedNotes = speed.RelevantNoteCount(); double speedNotes = speed.RelevantNoteCount();
double aimDifficultStrainCount = aim.CountTopWeightedStrains(); double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue);
double speedDifficultStrainCount = speed.CountTopWeightedStrains(); double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue);
double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders();
double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains();
double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount); double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount);
double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(); double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(speedDifficultyValue);
double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount); double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount);
double difficultSliders = aim.GetDifficultSliders(); double difficultSliders = aim.GetDifficultSliders();
double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate);
double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate);
int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
int totalHits = beatmap.HitObjects.Count; int totalHits = beatmap.HitObjects.Count;
double drainRate = beatmap.Difficulty.DrainRate; double sliderFactor = aimDifficultyValue > 0
? calculateAimDifficultyRating(aimNoSlidersDifficultyValue) / calculateAimDifficultyRating(aimDifficultyValue)
: 1;
double aimDifficultyValue = aim.DifficultyValue(); double aimRating = calculateAimDifficultyRating(aimDifficultyValue);
double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); double speedRating = calculateDifficultyRating(speedDifficultyValue);
double speedDifficultyValue = speed.DifficultyValue(); double readingRating = calculateDifficultyRating(readingDifficultyValue);
double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1;
var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor);
double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue);
double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue);
double flashlightRating = 0.0; double flashlightRating = 0.0;
if (flashlight is not null) if (flashlight is not null)
flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); flashlightRating = calculateDifficultyRating(flashlight.DifficultyValue());
double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap);
var simulator = new OsuLegacyScoreSimulator(); var simulator = new OsuLegacyScoreSimulator();
var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap);
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseAimPerformance = OsuPerformanceCalculator.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating);
double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating);
double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance);
double basePerformance = double basePerformance = DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance);
Math.Pow(
Math.Pow(baseAimPerformance, 1.1) +
Math.Pow(baseSpeedPerformance, 1.1) +
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
);
double starRating = calculateStarRating(basePerformance); double starRating = calculateStarRating(basePerformance);
@@ -127,12 +104,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficulty = speedRating, SpeedDifficulty = speedRating,
SpeedNoteCount = speedNotes, SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating, FlashlightDifficulty = flashlightRating,
ReadingDifficulty = readingRating,
SliderFactor = sliderFactor, SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultStrainCount, AimDifficultStrainCount = aimDifficultStrainCount,
SpeedDifficultStrainCount = speedDifficultStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount,
ReadingDifficultNoteCount = readingDifficultNoteCount,
AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, AimTopWeightedSliderFactor = aimTopWeightedSliderFactor,
SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor,
DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCircleCount, HitCircleCount = hitCircleCount,
SliderCount = sliderCount, SliderCount = sliderCount,
@@ -145,28 +123,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return attributes; return attributes;
} }
private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) public static double SumCognitionDifficulty(double reading, double flashlight)
{ {
double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); if (reading <= 0)
double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); return flashlight;
double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); if (flashlight <= 0)
return reading;
return calculateStarRating(totalValue); // Nerf flashlight value in cognition sum when reading is greater than flashlight
return DifficultyCalculationUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0));
} }
private double calculateAimDifficultyRating(double difficultyValue) => Math.Pow(difficultyValue, 0.63) * 0.02275;
private double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * 0.0675;
private double calculateStarRating(double basePerformance) private double calculateStarRating(double basePerformance)
{ {
if (basePerformance <= 0.00001) return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER);
return 0;
return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4);
} }
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods)
{ {
List<DifficultyHitObject> objects = new List<DifficultyHitObject>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
double clockRate = ModUtils.CalculateRateWithMods(mods);
// The first jump is formed by the first two hitobjects of the map. // The first jump is formed by the first two hitobjects of the map.
// If the map has less than two OsuHitObjects, the enumerator will not return anything. // If the map has less than two OsuHitObjects, the enumerator will not return anything.
for (int i = 1; i < beatmap.HitObjects.Count; i++) for (int i = 1; i < beatmap.HitObjects.Count; i++)
@@ -177,17 +160,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return objects; return objects;
} }
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods)
{ {
var skills = new List<Skill> var skills = new List<Skill>
{ {
new Aim(mods, true), new Aim(mods, true),
new Aim(mods, false), new Aim(mods, false),
new Speed(mods) new Speed(mods),
new Reading(mods)
}; };
if (mods.Any(h => h is OsuModFlashlight)) if (mods.Any(h => h is OsuModFlashlight))
skills.Add(new Flashlight(mods)); skills.Add(new Flashlight(mods, beatmap.HitObjects.Count));
return skills.ToArray(); return skills.ToArray();
} }
@@ -115,9 +115,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double missCount = 0; double missCount = 0;
// If sliders in the map are hard - it's likely for player to drop sliderends
// If map has easy sliders - it's more likely for player to sliderbreak
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map // In classic scores we can't know the amount of dropped sliders so we estimate it
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
if (score.MaxCombo < fullComboThreshold) if (score.MaxCombo < fullComboThreshold)
missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5);
@@ -21,6 +21,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight")] [JsonProperty("flashlight")]
public double Flashlight { get; set; } public double Flashlight { get; set; }
[JsonProperty("reading")]
public double Reading { get; set; }
[JsonProperty("effective_miss_count")] [JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; } public double EffectiveMissCount { get; set; }
@@ -48,6 +51,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed); yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed);
yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy);
yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight); yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight);
yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading);
} }
} }
} }
@@ -5,12 +5,15 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods; 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;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Utils; using osu.Game.Utils;
@@ -19,7 +22,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuPerformanceCalculator : PerformanceCalculator public class OsuPerformanceCalculator : PerformanceCalculator
{ {
public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
public const double PERFORMANCE_NORM_EXPONENT = 1.1;
private bool usingClassicSliderAccuracy; private bool usingClassicSliderAccuracy;
private bool usingScoreV2; private bool usingScoreV2;
@@ -50,14 +54,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double greatHitWindow; private double greatHitWindow;
private double okHitWindow; private double okHitWindow;
private double mehHitWindow; private double mehHitWindow;
private double overallDifficulty; private double overallDifficulty;
private double approachRate; private double approachRate;
private double drainRate;
private double? speedDeviation; private double? speedDeviation;
private double aimEstimatedSliderBreaks; private double aimEstimatedSliderBreaks;
private double speedEstimatedSliderBreaks; private double speedEstimatedSliderBreaks;
public static double DifficultyToPerformance(double difficulty) => 4.0 * Math.Pow(difficulty, 3.0);
public OsuPerformanceCalculator() public OsuPerformanceCalculator()
: base(new OsuRuleset()) : base(new OsuRuleset())
{ {
@@ -93,13 +101,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate;
mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate;
approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); approachRate = calculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); overallDifficulty = (79.5 - greatHitWindow) / 6;
drainRate = difficulty.DrainRate;
double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes);
double? scoreBasedEstimatedMissCount = null; double? scoreBasedEstimatedMissCount = null;
if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null)
{ {
var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes);
scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate();
@@ -115,6 +124,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
effectiveMissCount = Math.Min(totalHits, effectiveMissCount); effectiveMissCount = Math.Min(totalHits, effectiveMissCount);
if (effectiveMissCount > 0)
{
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.AimTopWeightedSliderFactor, osuAttributes);
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.SpeedTopWeightedSliderFactor, osuAttributes);
}
double multiplier = PERFORMANCE_BASE_MULTIPLIER; double multiplier = PERFORMANCE_BASE_MULTIPLIER;
if (score.Mods.Any(m => m is OsuModNoFail)) if (score.Mods.Any(m => m is OsuModNoFail))
@@ -140,15 +155,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double aimValue = computeAimValue(score, osuAttributes); double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes);
double accuracyValue = computeAccuracyValue(score, osuAttributes); double accuracyValue = computeAccuracyValue(score, osuAttributes);
double flashlightValue = computeFlashlightValue(score, osuAttributes);
double totalValue = double readingValue = computeReadingValue(osuAttributes);
Math.Pow( double flashlightValue = computeFlashlightValue(score, osuAttributes);
Math.Pow(aimValue, 1.1) + double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue);
Math.Pow(speedValue, 1.1) +
Math.Pow(accuracyValue, 1.1) + double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier;
Math.Pow(flashlightValue, 1.1), 1.0 / 1.1
) * multiplier;
return new OsuPerformanceAttributes return new OsuPerformanceAttributes
{ {
@@ -156,6 +168,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Speed = speedValue, Speed = speedValue,
Accuracy = accuracyValue, Accuracy = accuracyValue,
Flashlight = flashlightValue, Flashlight = flashlightValue,
Reading = readingValue,
EffectiveMissCount = effectiveMissCount, EffectiveMissCount = effectiveMissCount,
ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount,
ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount,
@@ -194,16 +207,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimDifficulty *= sliderNerfFactor; aimDifficulty *= sliderNerfFactor;
} }
double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); double aimValue = DifficultyToPerformance(aimDifficulty);
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + double lengthBonus = 0.95 + 0.35 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
aimValue *= lengthBonus; aimValue *= lengthBonus;
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
{ {
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes);
double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount); aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount);
@@ -211,10 +222,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate);
else if (score.Mods.Any(m => m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModTraceable))
{ {
aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor); aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor);
} }
aimValue *= accuracy; aimValue *= accuracy;
@@ -227,44 +238,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null) if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
return 0.0; return 0.0;
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
speedValue *= lengthBonus;
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
{ {
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes);
double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount);
} }
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
{ {
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
speedValue *= 1.12; speedValue *= 1.12;
} }
else if (score.Mods.Any(m => m is OsuModTraceable))
{
speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate);
}
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
speedValue *= speedHighDeviationMultiplier; speedValue *= speedHighDeviationMultiplier;
// Calculate accuracy assuming the worst case scenario // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window.
double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount); // For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10.
double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff); double effectiveHitWindow = 20 * Math.Pow(4 / attributes.SpeedDifficulty, 0.35);
double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat));
double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk));
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD. // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window.
speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation);
// Scale speed value by normalized accuracy.
speedValue *= Math.Pow(effectiveAccuracy, 2);
return speedValue; return speedValue;
} }
@@ -294,20 +294,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83;
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer. // Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); accuracyValue *= amountHitObjectsWithAccuracy < 1000
? Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)
: Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1);
// Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
accuracyValue *= 1.14; accuracyValue *= 1.14;
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModTraceable))
{ {
// Decrease bonus for AR > 10 // Decrease bonus for AR > 10
accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10);
} }
if (score.Mods.Any(m => m is OsuModFlashlight))
accuracyValue *= 1.02;
return accuracyValue; return accuracyValue;
} }
@@ -330,6 +329,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightValue; return flashlightValue;
} }
private double computeReadingValue(OsuDifficultyAttributes attributes)
{
double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty);
if (effectiveMissCount > 0)
readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount);
// Scale the reading value with accuracy _harshly_.
readingValue *= Math.Pow(accuracy, 3);
return readingValue;
}
private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes)
{ {
if (attributes.SliderCount <= 0) if (attributes.SliderCount <= 0)
@@ -339,9 +351,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (usingClassicSliderAccuracy) if (usingClassicSliderAccuracy)
{ {
// If sliders in the map are hard - it's likely for player to drop sliderends
// If map has easy sliders - it's more likely for player to sliderbreak
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map // In classic scores we can't know the amount of dropped sliders so we estimate it
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
if (scoreMaxCombo < fullComboThreshold) if (scoreMaxCombo < fullComboThreshold)
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
@@ -376,19 +392,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes)
{ {
if (!usingClassicSliderAccuracy || countOk == 0) int nonMissMistakes = countOk + countMeh;
if (!usingClassicSliderAccuracy || nonMissMistakes == 0)
return 0; return 0;
double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo;
double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); double estimatedSliderBreaks = Math.Min(nonMissMistakes, effectiveMissCount * topWeightedSliderFactor);
// Scores with more Oks are more likely to have slider breaks. // Scores with more Oks and Mehs are more likely to have slider breaks.
double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk; // We add an arbitrary value to both sides of the division to make it more stable on extreme ends.
double nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) / (nonMissMistakes + 4);
// There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. // There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred.
estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2);
return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); return estimatedSliderBreaks * nonMissMistakeAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
} }
/// <summary> /// <summary>
@@ -470,7 +489,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (speedDeviation == null) if (speedDeviation == null)
return 0; return 0;
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
// Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty. // Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty.
// This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value. // This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
@@ -489,12 +508,42 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return adjustedSpeedValue / speedValue; return adjustedSpeedValue / speedValue;
} }
/// <summary>
/// Calculates a visibility bonus that is applicable to Traceable.
/// </summary>
private double calculateTraceableBonus(double sliderFactor = 1)
{
// We want to reward slider aim less, more so at lower AR
double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2);
double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6);
// Start from normal curve, rewarding lower AR up to AR7
double traceableBonus = 0.0275;
traceableBonus += 0.025 * (12.0 - Math.Max(approachRate, 7)) * highApproachRateSliderVisibilityFactor;
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
traceableBonus += 0.025 * (7.0 - Math.Max(approachRate, 0)) * lowApproachRateSliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor;
return traceableBonus;
}
// Miss penalty assumes that a player will miss on the hardest parts of a map, // Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty // so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections. // to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1); private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private double calculateRateAdjustedApproachRate(double approachRate, double clockRate)
{
double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate;
return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
}
private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh; private int totalSuccessfulHits => countGreat + countOk + countMeh;
private int totalImperfectHits => countOk + countMeh + countMiss; private int totalImperfectHits => countOk + countMeh + countMiss;
@@ -1,213 +0,0 @@
// 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.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuRatingCalculator
{
private const double difficulty_multiplier = 0.0675;
private readonly Mod[] mods;
private readonly int totalHits;
private readonly double approachRate;
private readonly double overallDifficulty;
private readonly double mechanicalDifficultyRating;
private readonly double sliderFactor;
public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor)
{
this.mods = mods;
this.totalHits = totalHits;
this.approachRate = approachRate;
this.overallDifficulty = overallDifficulty;
this.mechanicalDifficultyRating = mechanicalDifficultyRating;
this.sliderFactor = sliderFactor;
}
public double ComputeAimRating(double aimDifficultyValue)
{
if (mods.Any(m => m is OsuModAutopilot))
return 0;
double aimRating = CalculateDifficultyRating(aimDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
aimRating = Math.Pow(aimRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
aimRating *= 0.9;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
aimRating *= 1.0 - magnetisedStrength;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
else if (approachRate < 8.0)
approachRateFactor = 0.05 * (8.0 - approachRate);
if (mods.Any(h => h is OsuModRelax))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateAimVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor);
}
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return aimRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeSpeedRating(double speedDifficultyValue)
{
if (mods.Any(m => m is OsuModRelax))
return 0;
double speedRating = CalculateDifficultyRating(speedDifficultyValue);
if (mods.Any(m => m is OsuModAutopilot))
speedRating *= 0.5;
if (mods.Any(m => m is OsuModMagnetised))
{
// reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
speedRating *= 1.0 - magnetisedStrength * 0.3;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
if (mods.Any(m => m is OsuModAutopilot))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateSpeedVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor);
}
ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750;
return speedRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeFlashlightRating(double flashlightDifficultyValue)
{
if (!mods.Any(m => m is OsuModFlashlight))
return 0;
double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
flashlightRating = Math.Pow(flashlightRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
flashlightRating *= 0.7;
else if (mods.Any(m => m is OsuModAutopilot))
flashlightRating *= 0.4;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
flashlightRating *= 1.0 - magnetisedStrength;
}
if (mods.Any(m => m is OsuModDeflate))
{
float deflateInitialScale = mods.OfType<OsuModDeflate>().First().StartScale.Value;
flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
}
double ratingMultiplier = 1.0;
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return flashlightRating * Math.Sqrt(ratingMultiplier);
}
private double calculateAimVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
private double calculateSpeedVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
/// <summary>
/// Calculates a visibility bonus that is applicable to Hidden and Traceable.
/// </summary>
public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1)
{
// NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side.
bool isAlwaysPartiallyVisible = mods.OfType<OsuModHidden>().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType<OsuModTraceable>().Any();
// Start from normal curve, rewarding lower AR up to AR7
// TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses
// This means it has an advantage over HD, so we decrease the multiplier to compensate
// This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible)
double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7));
readingBonus *= visibilityFactor;
// We want to reward slideraim on low AR less
double sliderVisibilityFactor = Math.Pow(sliderFactor, 3);
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor;
return readingBonus;
}
public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier;
}
}
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
@@ -35,6 +36,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary> /// </summary>
public readonly double AdjustedDeltaTime; public readonly double AdjustedDeltaTime;
/// <summary>
/// Amount of time elapsed between lastDifficultyObject's <see cref="DifficultyHitObject.EndTime"/> and <see cref="DifficultyHitObject.StartTime"/> capped to a minimum of <see cref="MIN_DELTA_TIME"/>ms.
/// </summary>
public double LastObjectEndDeltaTime { get; private set; }
/// <summary>
/// Time (in ms) between the object first appearing and the time it needs to be clicked.
/// <see cref="OsuHitObject.TimePreempt"/> adjusted by clock rate.
/// </summary>
public readonly double Preempt;
/// <summary>
/// Normalised distance from the start position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
public double JumpDistance { get; private set; }
/// <summary> /// <summary>
/// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>. /// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// <para> /// <para>
@@ -101,15 +118,29 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
public double? Angle { get; private set; } public double? Angle { get; private set; }
/// <summary> /// <summary>
/// Retrieves the full hit window for a Great <see cref="HitResult"/>. /// Angle of the vector created between current and current-1
/// normalised to consider symmetrical vectors in any axis to be the same angle.
/// </summary> /// </summary>
public double HitWindowGreat { get; private set; } public double? NormalisedVectorAngle { get; private set; }
/// <summary> /// <summary>
/// Selective bonus for maps with higher circle size. /// Selective bonus for maps with higher circle size.
/// </summary> /// </summary>
public double SmallCircleBonus { get; private set; } public double SmallCircleBonus { get; private set; }
/// <summary>
/// Object's immediate OverallDifficulty value calculated from the raw hitwindow.
/// </summary>
public double OverallDifficulty
{
get
{
double hitWindowGreat = RawHitWindow(HitResult.Great) / ClockRate;
return (79.5 - hitWindowGreat) / 6;
}
}
private readonly OsuDifficultyHitObject? lastLastDifficultyObject; private readonly OsuDifficultyHitObject? lastLastDifficultyObject;
private readonly OsuDifficultyHitObject? lastDifficultyObject; private readonly OsuDifficultyHitObject? lastDifficultyObject;
@@ -121,17 +152,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
LastObjectEndDeltaTime = lastDifficultyObject != null ? Math.Max(StartTime - lastDifficultyObject.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime;
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70);
if (BaseObject is Slider sliderObject) Preempt = BaseObject.TimePreempt / clockRate;
{
HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate;
}
else
{
HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate;
}
computeSliderCursorPosition(); computeSliderCursorPosition();
setDistances(clockRate); setDistances(clockRate);
@@ -148,7 +173,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
} }
double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt; double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt;
double fadeInDuration = BaseObject.TimeFadeIn;
// Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod.
double fadeInDuration = 400 * Math.Min(1, BaseObject.TimePreempt / OsuHitObject.PREEMPT_MIN);
if (hidden) if (hidden)
{ {
@@ -175,10 +202,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{ {
double currDeltaTime = Math.Max(1, DeltaTime); double currDeltaTime = Math.Max(1, DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2); double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindow(HitResult.Great)), 5);
return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
// Can't doubletap if circles don't intersect
double distanceFactor = Math.Pow(DifficultyCalculationUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2);
return 1.0 - Math.Pow(speedRatio, distanceFactor * (1 - windowRatio));
} }
return 0; return 0;
@@ -189,10 +222,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (BaseObject is Slider currentSlider) if (BaseObject is Slider currentSlider)
{ {
// Bonus for repeat sliders until a better per nested object strain system can be achieved. // Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); TravelDistance = LazyTravelDistance * Math.Max(1, Math.Pow(currentSlider.RepeatCount, 0.3));
TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME);
} }
MinimumJumpTime = AdjustedDeltaTime;
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
if (BaseObject is Spinner || LastObject is Spinner) if (BaseObject is Spinner || LastObject is Spinner)
return; return;
@@ -202,8 +237,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition;
LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor;
MinimumJumpTime = AdjustedDeltaTime; LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor;
MinimumJumpDistance = LazyJumpDistance; MinimumJumpDistance = LazyJumpDistance;
if (LastObject is Slider lastSlider && lastDifficultyObject != null) if (LastObject is Slider lastSlider && lastDifficultyObject != null)
@@ -239,15 +274,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner) if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner)
{ {
if (lastDifficultyObject!.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0)
lastCursorPosition = prevSlider.HeadCircle.StackedPosition;
Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject);
Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition; double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition);
Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition);
float dot = Vector2.Dot(v1, v2); Vector2 v = BaseObject.StackedPosition - lastCursorPosition;
float det = v1.X * v2.Y - v1.Y * v2.X; NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X));
Angle = Math.Abs(Math.Atan2(det, dot)); Angle = Math.Min(angle, sliderAngle);
} }
} }
@@ -359,6 +397,30 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
} }
} }
private double calculateSliderAngle(OsuDifficultyHitObject lastDifficultyObject, Vector2 lastLastCursorPosition)
{
Vector2 lastCursorPosition = getEndCursorPosition(lastDifficultyObject);
if (lastDifficultyObject.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0)
{
OsuHitObject secondLastNestedObject = (OsuHitObject)prevSlider.NestedHitObjects[^2];
lastLastCursorPosition = secondLastNestedObject.StackedPosition;
}
return calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition);
}
private double calculateAngle(Vector2 currentPosition, Vector2 lastPosition, Vector2 lastLastPosition)
{
Vector2 v1 = lastLastPosition - lastPosition;
Vector2 v2 = currentPosition - lastPosition;
float dot = Vector2.Dot(v1, v2);
float det = v1.X * v2.Y - v1.Y * v2.X;
return Math.Abs(Math.Atan2(det, dot));
}
private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject) private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject)
{ {
return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition; return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition;

Some files were not shown because too many files have changed in this diff Show More