2020-07-16 19:38:33 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
using System.Linq ;
using System.Threading ;
using System.Threading.Tasks ;
using osu.Framework.Allocation ;
2020-07-21 22:13:04 +08:00
using osu.Framework.Bindables ;
2022-01-03 16:31:12 +08:00
using osu.Framework.Extensions ;
2020-07-21 22:13:04 +08:00
using osu.Framework.Lists ;
2020-10-12 15:31:42 +08:00
using osu.Framework.Logging ;
2020-07-16 19:38:33 +08:00
using osu.Framework.Threading ;
2021-08-17 09:28:34 +08:00
using osu.Game.Configuration ;
2020-11-06 12:26:18 +08:00
using osu.Game.Database ;
2020-07-16 19:38:33 +08:00
using osu.Game.Rulesets ;
2021-10-05 10:26:13 +08:00
using osu.Game.Rulesets.Difficulty ;
2020-07-16 19:38:33 +08:00
using osu.Game.Rulesets.Mods ;
2020-10-12 15:31:42 +08:00
using osu.Game.Rulesets.UI ;
2020-07-16 19:38:33 +08:00
namespace osu.Game.Beatmaps
{
2020-11-06 12:14:23 +08:00
/// <summary>
/// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations.
/// Currently not persisted between game sessions.
/// </summary>
2021-11-20 23:54:58 +08:00
public class BeatmapDifficultyCache : MemoryCachingComponent < BeatmapDifficultyCache . DifficultyCacheLookup , StarDifficulty ? >
2020-07-16 19:38:33 +08:00
{
// Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes.
2020-11-06 12:14:23 +08:00
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler ( 1 , nameof ( BeatmapDifficultyCache ) ) ;
2020-07-16 19:38:33 +08:00
2020-11-10 13:31:27 +08:00
/// <summary>
/// All bindables that should be updated along with the current ruleset + mods.
/// </summary>
2020-11-10 00:10:00 +08:00
private readonly WeakList < BindableStarDifficulty > trackedBindables = new WeakList < BindableStarDifficulty > ( ) ;
2020-07-21 22:13:04 +08:00
2020-11-10 13:31:27 +08:00
/// <summary>
/// Cancellation sources used by tracked bindables.
/// </summary>
private readonly List < CancellationTokenSource > linkedCancellationSources = new List < CancellationTokenSource > ( ) ;
/// <summary>
/// Lock to be held when operating on <see cref="trackedBindables"/> or <see cref="linkedCancellationSources"/>.
/// </summary>
private readonly object bindableUpdateLock = new object ( ) ;
2022-06-23 18:44:38 +08:00
private CancellationTokenSource trackedUpdateCancellationSource = new CancellationTokenSource ( ) ;
2020-11-10 13:31:27 +08:00
2020-07-21 22:13:04 +08:00
[Resolved]
2022-06-23 18:44:38 +08:00
private BeatmapManager beatmapManager { get ; set ; } = null ! ;
2020-07-21 22:13:04 +08:00
[Resolved]
2022-06-23 18:44:38 +08:00
private Bindable < RulesetInfo > currentRuleset { get ; set ; } = null ! ;
2020-07-21 22:13:04 +08:00
[Resolved]
2022-06-23 18:44:38 +08:00
private Bindable < IReadOnlyList < Mod > > currentMods { get ; set ; } = null ! ;
2020-07-21 22:13:04 +08:00
2022-06-23 18:44:38 +08:00
private ModSettingChangeTracker ? modSettingChangeTracker ;
private ScheduledDelegate ? debouncedModSettingsChange ;
2021-08-17 09:28:34 +08:00
2020-07-21 22:13:04 +08:00
protected override void LoadComplete ( )
{
base . LoadComplete ( ) ;
2022-06-24 15:53:04 +08:00
currentRuleset . BindValueChanged ( _ = > Scheduler . AddOnce ( updateTrackedBindables ) ) ;
2021-08-17 09:28:34 +08:00
currentMods . BindValueChanged ( mods = >
{
modSettingChangeTracker ? . Dispose ( ) ;
2022-06-24 15:53:04 +08:00
Scheduler . AddOnce ( updateTrackedBindables ) ;
2021-08-17 09:28:34 +08:00
modSettingChangeTracker = new ModSettingChangeTracker ( mods . NewValue ) ;
modSettingChangeTracker . SettingChanged + = _ = >
{
debouncedModSettingsChange ? . Cancel ( ) ;
debouncedModSettingsChange = Scheduler . AddDelayed ( updateTrackedBindables , 100 ) ;
} ;
} , true ) ;
2020-07-21 22:13:04 +08:00
}
/// <summary>
2020-07-24 12:52:43 +08:00
/// Retrieves a bindable containing the star difficulty of a <see cref="BeatmapInfo"/> that follows the currently-selected ruleset and mods.
2020-07-21 22:13:04 +08:00
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
2021-02-25 15:22:40 +08:00
/// <returns>A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state (but not during updates to ruleset and mods if a stale value is already propagated).</returns>
2022-06-23 18:44:38 +08:00
public IBindable < StarDifficulty ? > GetBindableDifficulty ( IBeatmapInfo beatmapInfo , CancellationToken cancellationToken = default )
2020-07-24 12:52:43 +08:00
{
2022-06-24 15:53:14 +08:00
var bindable = new BindableStarDifficulty ( beatmapInfo , cancellationToken ) ;
updateBindable ( bindable , currentRuleset . Value , currentMods . Value , cancellationToken ) ;
2020-11-10 00:10:00 +08:00
2020-11-10 13:31:27 +08:00
lock ( bindableUpdateLock )
2020-11-10 00:10:00 +08:00
trackedBindables . Add ( bindable ) ;
2020-07-24 12:52:43 +08:00
return bindable ;
}
2020-07-21 22:13:04 +08:00
/// <summary>
2021-10-29 15:45:10 +08:00
/// Retrieves the difficulty of a <see cref="IBeatmapInfo"/>.
2020-07-21 22:13:04 +08:00
/// </summary>
2021-10-29 15:45:10 +08:00
/// <param name="beatmapInfo">The <see cref="IBeatmapInfo"/> to get the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="IRulesetInfo"/> to get the difficulty with.</param>
2020-07-21 22:13:04 +08:00
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops computing the star difficulty.</param>
2021-11-20 23:54:58 +08:00
/// <returns>
/// The requested <see cref="StarDifficulty"/>, if non-<see langword="null"/>.
/// A <see langword="null"/> return value indicates that the difficulty process failed or was interrupted early,
/// and as such there is no usable star difficulty value to be returned.
/// </returns>
2022-06-23 18:44:38 +08:00
public virtual Task < StarDifficulty ? > GetDifficultyAsync ( IBeatmapInfo beatmapInfo , IRulesetInfo ? rulesetInfo = null ,
IEnumerable < Mod > ? mods = null , CancellationToken cancellationToken = default )
2020-07-16 19:38:33 +08:00
{
2020-11-06 13:31:21 +08:00
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
rulesetInfo ? ? = beatmapInfo . Ruleset ;
2020-07-16 19:38:33 +08:00
2021-10-29 15:45:10 +08:00
var localBeatmapInfo = beatmapInfo as BeatmapInfo ;
var localRulesetInfo = rulesetInfo as RulesetInfo ;
2020-11-06 13:31:21 +08:00
// Difficulty can only be computed if the beatmap and ruleset are locally available.
2022-01-13 12:19:49 +08:00
if ( localBeatmapInfo = = null | | localRulesetInfo = = null )
2020-08-28 21:08:28 +08:00
{
2020-11-06 13:31:21 +08:00
// If not, fall back to the existing star difficulty (e.g. from an online source).
2021-11-20 23:54:58 +08:00
return Task . FromResult < StarDifficulty ? > ( new StarDifficulty ( beatmapInfo . StarRating , ( beatmapInfo as IBeatmapOnlineInfo ) ? . MaxCombo ? ? 0 ) ) ;
2020-11-06 13:31:21 +08:00
}
2021-10-29 15:45:10 +08:00
return GetAsync ( new DifficultyCacheLookup ( localBeatmapInfo , localRulesetInfo , mods ) , cancellationToken ) ;
2020-11-06 13:31:21 +08:00
}
2021-11-20 23:54:58 +08:00
protected override Task < StarDifficulty ? > ComputeValueAsync ( DifficultyCacheLookup lookup , CancellationToken cancellationToken = default )
2020-11-06 13:31:21 +08:00
{
return Task . Factory . StartNew ( ( ) = >
{
if ( CheckExists ( lookup , out var existing ) )
2020-08-28 21:08:28 +08:00
return existing ;
2021-11-16 13:45:51 +08:00
return computeDifficulty ( lookup , cancellationToken ) ;
} , cancellationToken , TaskCreationOptions . HideScheduler | TaskCreationOptions . RunContinuationsAsynchronously , updateScheduler ) ;
2020-07-16 20:07:14 +08:00
}
2020-07-16 19:38:33 +08:00
2021-11-20 23:54:58 +08:00
protected override bool CacheNullValues = > false ;
2021-11-18 04:52:30 +08:00
public Task < List < TimedDifficultyAttributes > > GetTimedDifficultyAttributesAsync ( IWorkingBeatmap beatmap , Ruleset ruleset , Mod [ ] mods , CancellationToken cancellationToken = default )
2021-10-05 10:26:13 +08:00
{
2021-11-16 13:45:51 +08:00
return Task . Factory . StartNew ( ( ) = > ruleset . CreateDifficultyCalculator ( beatmap ) . CalculateTimed ( mods , cancellationToken ) ,
cancellationToken ,
2021-10-05 10:26:13 +08:00
TaskCreationOptions . HideScheduler | TaskCreationOptions . RunContinuationsAsynchronously ,
updateScheduler ) ;
}
2020-07-21 22:13:04 +08:00
/// <summary>
/// Updates all tracked <see cref="BindableStarDifficulty"/> using the current ruleset and mods.
/// </summary>
private void updateTrackedBindables ( )
{
2020-11-10 13:31:27 +08:00
lock ( bindableUpdateLock )
2020-07-21 22:13:04 +08:00
{
2020-11-10 00:10:00 +08:00
cancelTrackedBindableUpdate ( ) ;
2020-07-28 15:52:07 +08:00
2020-11-10 00:10:00 +08:00
foreach ( var b in trackedBindables )
{
var linkedSource = CancellationTokenSource . CreateLinkedTokenSource ( trackedUpdateCancellationSource . Token , b . CancellationToken ) ;
linkedCancellationSources . Add ( linkedSource ) ;
updateBindable ( b , currentRuleset . Value , currentMods . Value , linkedSource . Token ) ;
}
2020-07-28 15:52:07 +08:00
}
}
/// <summary>
/// Cancels the existing update of all tracked <see cref="BindableStarDifficulty"/> via <see cref="updateTrackedBindables"/>.
/// </summary>
private void cancelTrackedBindableUpdate ( )
{
2020-11-10 13:31:27 +08:00
lock ( bindableUpdateLock )
2020-07-29 10:30:25 +08:00
{
2022-06-23 18:44:38 +08:00
trackedUpdateCancellationSource . Cancel ( ) ;
trackedUpdateCancellationSource = new CancellationTokenSource ( ) ;
2020-11-10 00:10:00 +08:00
2022-06-23 18:44:38 +08:00
foreach ( var c in linkedCancellationSources )
c . Dispose ( ) ;
2020-07-28 15:52:07 +08:00
2022-06-23 18:44:38 +08:00
linkedCancellationSources . Clear ( ) ;
2020-07-29 10:30:25 +08:00
}
2020-07-21 22:13:04 +08:00
}
/// <summary>
/// Updates the value of a <see cref="BindableStarDifficulty"/> with a given ruleset + mods.
/// </summary>
/// <param name="bindable">The <see cref="BindableStarDifficulty"/> to update.</param>
2021-10-29 15:45:10 +08:00
/// <param name="rulesetInfo">The <see cref="IRulesetInfo"/> to update with.</param>
2020-07-21 22:13:04 +08:00
/// <param name="mods">The <see cref="Mod"/>s to update with.</param>
/// <param name="cancellationToken">A token that may be used to cancel this update.</param>
2022-06-23 18:44:38 +08:00
private void updateBindable ( BindableStarDifficulty bindable , IRulesetInfo ? rulesetInfo , IEnumerable < Mod > ? mods , CancellationToken cancellationToken = default )
2020-07-21 22:13:04 +08:00
{
2021-10-29 15:45:10 +08:00
// GetDifficultyAsync will fall back to existing data from IBeatmapInfo if not locally available
2020-11-08 07:12:25 +08:00
// (contrary to GetAsync)
2021-10-02 23:55:29 +08:00
GetDifficultyAsync ( bindable . BeatmapInfo , rulesetInfo , mods , cancellationToken )
2022-01-05 14:54:10 +08:00
. ContinueWith ( task = >
2020-07-21 22:13:04 +08:00
{
2020-11-06 13:31:21 +08:00
// We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events.
Schedule ( ( ) = >
{
2022-01-03 16:31:12 +08:00
if ( cancellationToken . IsCancellationRequested )
return ;
2022-01-06 21:54:43 +08:00
var starDifficulty = task . GetResultSafely ( ) ;
2022-01-05 14:54:10 +08:00
if ( starDifficulty ! = null )
bindable . Value = starDifficulty . Value ;
2020-11-06 13:31:21 +08:00
} ) ;
} , cancellationToken ) ;
2020-07-21 22:13:04 +08:00
}
/// <summary>
/// Computes the difficulty defined by a <see cref="DifficultyCacheLookup"/> key, and stores it to the timed cache.
/// </summary>
/// <param name="key">The <see cref="DifficultyCacheLookup"/> that defines the computation parameters.</param>
2021-11-06 07:19:48 +08:00
/// <param name="cancellationToken">The cancellation token.</param>
2020-07-21 22:13:04 +08:00
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2021-11-20 23:54:58 +08:00
private StarDifficulty ? computeDifficulty ( in DifficultyCacheLookup key , CancellationToken cancellationToken = default )
2020-07-16 20:07:14 +08:00
{
2020-07-22 11:48:12 +08:00
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
2021-10-02 23:55:29 +08:00
var beatmapInfo = key . BeatmapInfo ;
2020-11-06 13:31:21 +08:00
var rulesetInfo = key . Ruleset ;
2020-07-22 11:48:12 +08:00
2020-07-16 19:38:33 +08:00
try
{
2020-07-21 22:50:54 +08:00
var ruleset = rulesetInfo . CreateInstance ( ) ;
2020-07-16 19:38:33 +08:00
Debug . Assert ( ruleset ! = null ) ;
2021-10-02 23:55:29 +08:00
var calculator = ruleset . CreateDifficultyCalculator ( beatmapManager . GetWorkingBeatmap ( key . BeatmapInfo ) ) ;
2021-11-06 07:19:48 +08:00
var attributes = calculator . Calculate ( key . OrderedMods , cancellationToken ) ;
2020-07-16 19:38:33 +08:00
2020-11-06 13:31:21 +08:00
return new StarDifficulty ( attributes ) ;
2020-07-16 19:38:33 +08:00
}
2021-11-21 00:32:40 +08:00
catch ( OperationCanceledException )
{
// no need to log, cancellations are expected as part of normal operation.
return null ;
}
catch ( BeatmapInvalidForRulesetException invalidForRuleset )
2020-10-12 15:31:42 +08:00
{
if ( rulesetInfo . Equals ( beatmapInfo . Ruleset ) )
2021-11-21 00:32:40 +08:00
Logger . Error ( invalidForRuleset , $"Failed to convert {beatmapInfo.OnlineID} to the beatmap's default ruleset ({beatmapInfo.Ruleset})." ) ;
2020-10-12 15:31:42 +08:00
2021-11-20 23:54:58 +08:00
return null ;
2020-10-12 15:31:42 +08:00
}
2021-11-21 00:32:40 +08:00
catch ( Exception unknownException )
2020-07-16 19:38:33 +08:00
{
2021-11-21 00:32:40 +08:00
Logger . Error ( unknownException , "Failed to calculate beatmap difficulty" ) ;
2021-11-20 23:54:58 +08:00
return null ;
2020-07-16 20:07:14 +08:00
}
}
2020-07-28 15:52:07 +08:00
protected override void Dispose ( bool isDisposing )
{
base . Dispose ( isDisposing ) ;
2020-07-28 16:23:35 +08:00
2021-08-17 09:28:34 +08:00
modSettingChangeTracker ? . Dispose ( ) ;
2020-07-28 15:52:07 +08:00
cancelTrackedBindableUpdate ( ) ;
2022-06-23 18:44:38 +08:00
updateScheduler . Dispose ( ) ;
2020-07-28 15:52:07 +08:00
}
2020-08-28 21:12:17 +08:00
public readonly struct DifficultyCacheLookup : IEquatable < DifficultyCacheLookup >
2020-07-16 19:38:33 +08:00
{
2021-10-02 23:55:29 +08:00
public readonly BeatmapInfo BeatmapInfo ;
2020-11-06 13:31:21 +08:00
public readonly RulesetInfo Ruleset ;
public readonly Mod [ ] OrderedMods ;
2020-07-16 19:38:33 +08:00
2022-06-23 18:44:38 +08:00
public DifficultyCacheLookup ( BeatmapInfo beatmapInfo , RulesetInfo ? ruleset , IEnumerable < Mod > ? mods )
2020-07-16 19:38:33 +08:00
{
2021-10-02 23:55:29 +08:00
BeatmapInfo = beatmapInfo ;
2020-11-06 15:58:53 +08:00
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
2021-10-02 23:55:29 +08:00
Ruleset = ruleset ? ? BeatmapInfo . Ruleset ;
Fix difficulty cache lookups sharing underlying mod instances
`DifficultyCacheLookup`s were storing raw `Mod` instances into their
`OrderedMods` field. This could cause the cache lookups to wrongly
succeed in cases of mods with settings. The particular case that
triggered this fix was Difficulty Adjust.
Because the difficulty cache is backed by a dictionary, there are two
stages to the lookup; first `GetHashCode()` is used to find the
appropriate hash bucket to look in, and then items from that hash bucket
are compared against the key being searched for via the implementation
of `Equals()`.
As it turns out, the first hashing step ended up being the saving grace
in most cases, as the hash computation included the values of the mod
settings. But the Difficulty Adjust failure case was triggered by the
quirk that `GetHashCode(0) == GetHashCode(null) == 0`.
In such a case, the `Equals()` fallback was used. But as it turns out,
because the `Mod` instance stored to lookups was not cloned and
therefore potentially externally mutable, it could be polluted after
being stored to the dictionary, and therefore breaking the equality
check. Even though all of the setting values were compared, the hash
bucket didn't match the actual contents of the lookup anymore (because
they were mutated externally, e.g. by the user changing the mod setting
values in the mod settings overlay).
To resolve, clone out the mod structure before creating all difficulty
lookups.
2021-08-21 21:39:02 +08:00
OrderedMods = mods ? . OrderBy ( m = > m . Acronym ) . Select ( mod = > mod . DeepClone ( ) ) . ToArray ( ) ? ? Array . Empty < Mod > ( ) ;
2020-07-16 19:38:33 +08:00
}
public bool Equals ( DifficultyCacheLookup other )
2021-11-24 11:49:57 +08:00
= > BeatmapInfo . Equals ( other . BeatmapInfo )
& & Ruleset . Equals ( other . Ruleset )
2021-08-17 09:27:43 +08:00
& & OrderedMods . SequenceEqual ( other . OrderedMods ) ;
2020-07-16 19:38:33 +08:00
public override int GetHashCode ( )
{
var hashCode = new HashCode ( ) ;
2021-10-02 23:55:29 +08:00
hashCode . Add ( BeatmapInfo . ID ) ;
2021-11-24 14:25:49 +08:00
hashCode . Add ( Ruleset . ShortName ) ;
2020-11-06 13:31:21 +08:00
foreach ( var mod in OrderedMods )
2021-08-17 09:27:43 +08:00
hashCode . Add ( mod ) ;
2020-07-16 19:38:33 +08:00
return hashCode . ToHashCode ( ) ;
}
}
2020-07-21 22:13:04 +08:00
2021-02-25 15:19:01 +08:00
private class BindableStarDifficulty : Bindable < StarDifficulty ? >
2020-07-21 22:13:04 +08:00
{
2021-10-29 15:45:10 +08:00
public readonly IBeatmapInfo BeatmapInfo ;
2020-07-21 22:13:04 +08:00
public readonly CancellationToken CancellationToken ;
2021-10-29 15:45:10 +08:00
public BindableStarDifficulty ( IBeatmapInfo beatmapInfo , CancellationToken cancellationToken )
2020-07-21 22:13:04 +08:00
{
2021-10-02 23:55:29 +08:00
BeatmapInfo = beatmapInfo ;
2020-07-21 22:13:04 +08:00
CancellationToken = cancellationToken ;
}
}
}
2020-07-16 19:38:33 +08:00
}