// Copyright (c) ppy Pty Ltd . 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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; namespace osu.Game.Beatmaps { /// /// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations. /// Currently not persisted between game sessions. /// public class BeatmapDifficultyCache : MemoryCachingComponent { // Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes. private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(BeatmapDifficultyCache)); /// /// All bindables that should be updated along with the current ruleset + mods. /// private readonly WeakList trackedBindables = new WeakList(); /// /// Cancellation sources used by tracked bindables. /// private readonly List linkedCancellationSources = new List(); /// /// Lock to be held when operating on or . /// private readonly object bindableUpdateLock = new object(); private CancellationTokenSource trackedUpdateCancellationSource; [Resolved] private BeatmapManager beatmapManager { get; set; } [Resolved] private Bindable currentRuleset { get; set; } [Resolved] private Bindable> currentMods { get; set; } private ModSettingChangeTracker modSettingChangeTracker; private ScheduledDelegate debouncedModSettingsChange; protected override void LoadComplete() { base.LoadComplete(); currentRuleset.BindValueChanged(_ => updateTrackedBindables()); currentMods.BindValueChanged(mods => { modSettingChangeTracker?.Dispose(); updateTrackedBindables(); modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); modSettingChangeTracker.SettingChanged += _ => { debouncedModSettingsChange?.Cancel(); debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100); }; }, true); } /// /// Retrieves a bindable containing the star difficulty of a that follows the currently-selected ruleset and mods. /// /// The to get the difficulty of. /// An optional which stops updating the star difficulty for the given . /// 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). public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) { var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); lock (bindableUpdateLock) trackedBindables.Add(bindable); return bindable; } /// /// Retrieves a bindable containing the star difficulty of a with a given and combination. /// /// /// The bindable will not update to follow the currently-selected ruleset and mods or its settings. /// /// The to get the difficulty of. /// The to get the difficulty with. If null, the 's ruleset is used. /// The s to get the difficulty with. If null, no mods will be assumed. /// An optional which stops updating the star difficulty for the given . /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state. public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); /// /// Retrieves the difficulty of a . /// /// The to get the difficulty of. /// The to get the difficulty with. /// The s to get the difficulty with. /// An optional which stops computing the star difficulty. /// The . public virtual Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null, CancellationToken cancellationToken = default) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. rulesetInfo ??= beatmapInfo.Ruleset; // Difficulty can only be computed if the beatmap and ruleset are locally available. if (beatmapInfo.ID == 0 || rulesetInfo.ID == null) { // If not, fall back to the existing star difficulty (e.g. from an online source). return Task.FromResult(new StarDifficulty(beatmapInfo.StarDifficulty, beatmapInfo.MaxCombo ?? 0)); } return GetAsync(new DifficultyCacheLookup(beatmapInfo, rulesetInfo, mods), cancellationToken); } protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken token = default) { return Task.Factory.StartNew(() => { if (CheckExists(lookup, out var existing)) return existing; return computeDifficulty(lookup); }, token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } public Task> GetTimedDifficultyAttributesAsync(WorkingBeatmap beatmap, Ruleset ruleset, Mod[] mods, CancellationToken token = default) { return Task.Factory.StartNew(() => ruleset.CreateDifficultyCalculator(beatmap).CalculateTimed(mods), token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } /// /// Retrieves the that describes a star rating. /// /// /// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties /// /// The star rating. /// The that best describes . public static DifficultyRating GetDifficultyRating(double starRating) { if (Precision.AlmostBigger(starRating, 6.5, 0.005)) return DifficultyRating.ExpertPlus; if (Precision.AlmostBigger(starRating, 5.3, 0.005)) return DifficultyRating.Expert; if (Precision.AlmostBigger(starRating, 4.0, 0.005)) return DifficultyRating.Insane; if (Precision.AlmostBigger(starRating, 2.7, 0.005)) return DifficultyRating.Hard; if (Precision.AlmostBigger(starRating, 2.0, 0.005)) return DifficultyRating.Normal; return DifficultyRating.Easy; } /// /// Updates all tracked using the current ruleset and mods. /// private void updateTrackedBindables() { lock (bindableUpdateLock) { cancelTrackedBindableUpdate(); trackedUpdateCancellationSource = new CancellationTokenSource(); foreach (var b in trackedBindables) { var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken); linkedCancellationSources.Add(linkedSource); updateBindable(b, currentRuleset.Value, currentMods.Value, linkedSource.Token); } } } /// /// Cancels the existing update of all tracked via . /// private void cancelTrackedBindableUpdate() { lock (bindableUpdateLock) { trackedUpdateCancellationSource?.Cancel(); trackedUpdateCancellationSource = null; if (linkedCancellationSources != null) { foreach (var c in linkedCancellationSources) c.Dispose(); linkedCancellationSources.Clear(); } } } /// /// Creates a new and triggers an initial value update. /// /// The that star difficulty should correspond to. /// The initial to get the difficulty with. /// The initial s to get the difficulty with. /// An optional which stops updating the star difficulty for the given . /// The . private BindableStarDifficulty createBindable([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo initialRulesetInfo, [CanBeNull] IEnumerable initialMods, CancellationToken cancellationToken) { var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); updateBindable(bindable, initialRulesetInfo, initialMods, cancellationToken); return bindable; } /// /// Updates the value of a with a given ruleset + mods. /// /// The to update. /// The to update with. /// The s to update with. /// A token that may be used to cancel this update. private void updateBindable([NotNull] BindableStarDifficulty bindable, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) { // GetDifficultyAsync will fall back to existing data from BeatmapInfo if not locally available // (contrary to GetAsync) GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken) .ContinueWith(t => { // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. Schedule(() => { if (!cancellationToken.IsCancellationRequested) bindable.Value = t.Result; }); }, cancellationToken); } /// /// Computes the difficulty defined by a key, and stores it to the timed cache. /// /// The that defines the computation parameters. /// The . private StarDifficulty computeDifficulty(in DifficultyCacheLookup key) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. var beatmapInfo = key.BeatmapInfo; var rulesetInfo = key.Ruleset; try { var ruleset = rulesetInfo.CreateInstance(); Debug.Assert(ruleset != null); var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo)); var attributes = calculator.Calculate(key.OrderedMods); return new StarDifficulty(attributes); } catch (BeatmapInvalidForRulesetException e) { if (rulesetInfo.Equals(beatmapInfo.Ruleset)) Logger.Error(e, $"Failed to convert {beatmapInfo.OnlineBeatmapID} to the beatmap's default ruleset ({beatmapInfo.Ruleset})."); return new StarDifficulty(); } catch { return new StarDifficulty(); } } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); modSettingChangeTracker?.Dispose(); cancelTrackedBindableUpdate(); updateScheduler?.Dispose(); } public readonly struct DifficultyCacheLookup : IEquatable { public readonly BeatmapInfo BeatmapInfo; public readonly RulesetInfo Ruleset; public readonly Mod[] OrderedMods; public DifficultyCacheLookup([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo ruleset, IEnumerable mods) { BeatmapInfo = beatmapInfo; // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. Ruleset = ruleset ?? BeatmapInfo.Ruleset; OrderedMods = mods?.OrderBy(m => m.Acronym).Select(mod => mod.DeepClone()).ToArray() ?? Array.Empty(); } public bool Equals(DifficultyCacheLookup other) => BeatmapInfo.ID == other.BeatmapInfo.ID && Ruleset.ID == other.Ruleset.ID && OrderedMods.SequenceEqual(other.OrderedMods); public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(BeatmapInfo.ID); hashCode.Add(Ruleset.ID); foreach (var mod in OrderedMods) hashCode.Add(mod); return hashCode.ToHashCode(); } } private class BindableStarDifficulty : Bindable { public readonly BeatmapInfo BeatmapInfo; public readonly CancellationToken CancellationToken; public BindableStarDifficulty(BeatmapInfo beatmapInfo, CancellationToken cancellationToken) { BeatmapInfo = beatmapInfo; CancellationToken = cancellationToken; } } } }