// 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 JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Skinning; using osu.Game.Storyboards; namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; public readonly BeatmapSetInfo BeatmapSetInfo; public readonly BeatmapMetadata Metadata; protected AudioManager AudioManager { get; } private static readonly GlobalStatistic<int> total_count = GlobalStatistics.Get<int>(nameof(Beatmaps), $"Total {nameof(WorkingBeatmap)}s"); protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { AudioManager = audioManager; BeatmapInfo = beatmapInfo; BeatmapSetInfo = beatmapInfo.BeatmapSet; Metadata = beatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); background = new RecyclableLazy<Texture>(GetBackground, BackgroundStillValid); waveform = new RecyclableLazy<Waveform>(GetWaveform); storyboard = new RecyclableLazy<Storyboard>(GetStoryboard); skin = new RecyclableLazy<ISkin>(GetSkin); total_count.Value++; } protected virtual Track GetVirtualTrack(double emptyLength = 0) { const double excess_length = 1000; var lastObject = Beatmap?.HitObjects.LastOrDefault(); double length; switch (lastObject) { case null: length = emptyLength; break; case IHasDuration endTime: length = endTime.EndTime + excess_length; break; default: length = lastObject.StartTime + excess_length; break; } return AudioManager.Tracks.GetVirtual(length); } /// <summary> /// Creates a <see cref="IBeatmapConverter"/> to convert a <see cref="IBeatmap"/> for a specified <see cref="Ruleset"/>. /// </summary> /// <param name="beatmap">The <see cref="IBeatmap"/> to be converted.</param> /// <param name="ruleset">The <see cref="Ruleset"/> for which <paramref name="beatmap"/> should be converted.</param> /// <returns>The applicable <see cref="IBeatmapConverter"/>.</returns> protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); public IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList<Mod> mods = null, TimeSpan? timeout = null) { using (var cancellationSource = createCancellationTokenSource(timeout)) { mods ??= Array.Empty<Mod>(); var rulesetInstance = ruleset.CreateInstance(); IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance); // Check if the beatmap can be converted if (Beatmap.HitObjects.Count > 0 && !converter.CanConvert()) throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter})."); // Apply conversion mods foreach (var mod in mods.OfType<IApplicableToBeatmapConverter>()) { if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToBeatmapConverter(converter); } // Convert IBeatmap converted = converter.Convert(cancellationSource.Token); // Apply conversion mods to the result foreach (var mod in mods.OfType<IApplicableAfterBeatmapConversion>()) { if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToBeatmap(converted); } // Apply difficulty mods if (mods.Any(m => m is IApplicableToDifficulty)) { converted.BeatmapInfo = converted.BeatmapInfo.Clone(); converted.BeatmapInfo.BaseDifficulty = converted.BeatmapInfo.BaseDifficulty.Clone(); foreach (var mod in mods.OfType<IApplicableToDifficulty>()) { if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToDifficulty(converted.BeatmapInfo.BaseDifficulty); } } IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); processor?.PreProcess(); // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed try { foreach (var obj in converted.HitObjects) { if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); obj.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty, cancellationSource.Token); } } catch (OperationCanceledException) { throw new BeatmapLoadTimeoutException(BeatmapInfo); } foreach (var mod in mods.OfType<IApplicableToHitObject>()) { foreach (var obj in converted.HitObjects) { if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToHitObject(obj); } } processor?.PostProcess(); foreach (var mod in mods.OfType<IApplicableToBeatmap>()) { cancellationSource.Token.ThrowIfCancellationRequested(); mod.ApplyToBeatmap(converted); } return converted; } } private CancellationTokenSource loadCancellation = new CancellationTokenSource(); /// <summary> /// Beings loading the contents of this <see cref="WorkingBeatmap"/> asynchronously. /// </summary> public void BeginAsyncLoad() { loadBeatmapAsync(); } /// <summary> /// Cancels the asynchronous loading of the contents of this <see cref="WorkingBeatmap"/>. /// </summary> public void CancelAsyncLoad() { loadCancellation?.Cancel(); loadCancellation = new CancellationTokenSource(); if (beatmapLoadTask?.IsCompleted != true) beatmapLoadTask = null; } private CancellationTokenSource createCancellationTokenSource(TimeSpan? timeout) { if (Debugger.IsAttached) // ignore timeout when debugger is attached (may be breakpointing / debugging). return new CancellationTokenSource(); return new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(10)); } private Task<IBeatmap> loadBeatmapAsync() => beatmapLoadTask ??= Task.Factory.StartNew(() => { // Todo: Handle cancellation during beatmap parsing var b = GetBeatmap() ?? new Beatmap(); // The original beatmap version needs to be preserved as the database doesn't contain it BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) b.BeatmapInfo = BeatmapInfo; return b; }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); public override string ToString() => BeatmapInfo.ToString(); public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; public IBeatmap Beatmap { get { try { return loadBeatmapAsync().Result; } catch (AggregateException ae) { // This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) return null; Logger.Error(ae, "Beatmap failed to load"); return null; } catch (Exception e) { Logger.Error(e, "Beatmap failed to load"); return null; } } } protected abstract IBeatmap GetBeatmap(); private Task<IBeatmap> beatmapLoadTask; public bool BackgroundLoaded => background.IsResultAvailable; public Texture Background => background.Value; protected virtual bool BackgroundStillValid(Texture b) => b == null || b.Available; protected abstract Texture GetBackground(); private readonly RecyclableLazy<Texture> background; private Track loadedTrack; [NotNull] public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); /// <summary> /// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap /// across difficulties in the same beatmap set. /// </summary> /// <param name="track">The track to transfer.</param> public void TransferTrack([NotNull] Track track) => loadedTrack = track ?? throw new ArgumentNullException(nameof(track)); /// <summary> /// Whether this beatmap's track has been loaded via <see cref="LoadTrack"/>. /// </summary> public virtual bool TrackLoaded => loadedTrack != null; /// <summary> /// Get the loaded audio track instance. <see cref="LoadTrack"/> must have first been called. /// This generally happens via MusicController when changing the global beatmap. /// </summary> public Track Track { get { if (!TrackLoaded) throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}."); return loadedTrack; } } protected abstract Track GetBeatmapTrack(); public bool WaveformLoaded => waveform.IsResultAvailable; public Waveform Waveform => waveform.Value; protected virtual Waveform GetWaveform() => new Waveform(null); private readonly RecyclableLazy<Waveform> waveform; public bool StoryboardLoaded => storyboard.IsResultAvailable; public Storyboard Storyboard => storyboard.Value; protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; private readonly RecyclableLazy<Storyboard> storyboard; public bool SkinLoaded => skin.IsResultAvailable; public ISkin Skin => skin.Value; protected virtual ISkin GetSkin() => new DefaultSkin(); private readonly RecyclableLazy<ISkin> skin; ~WorkingBeatmap() { total_count.Value--; } public class RecyclableLazy<T> { private Lazy<T> lazy; private readonly Func<T> valueFactory; private readonly Func<T, bool> stillValidFunction; private readonly object fetchLock = new object(); public RecyclableLazy(Func<T> valueFactory, Func<T, bool> stillValidFunction = null) { this.valueFactory = valueFactory; this.stillValidFunction = stillValidFunction; recreate(); } public void Recycle() { if (!IsResultAvailable) return; (lazy.Value as IDisposable)?.Dispose(); recreate(); } public bool IsResultAvailable => stillValid; public T Value { get { lock (fetchLock) { if (!stillValid) recreate(); return lazy.Value; } } } private bool stillValid => lazy.IsValueCreated && (stillValidFunction?.Invoke(lazy.Value) ?? true); private void recreate() => lazy = new Lazy<T>(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication); } private class BeatmapLoadTimeoutException : TimeoutException { public BeatmapLoadTimeoutException(BeatmapInfo beatmapInfo) : base($"Timed out while loading beatmap ({beatmapInfo}).") { } } } }