using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Dalamud.Interface.Internal; using Dalamud.Interface.Textures.Internal; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Textures.TextureWraps.Internal; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Networking.Http; using Dalamud.Utility; using Dalamud.Utility.Timing; using JetBrains.Annotations; using Serilog; namespace Dalamud.Storage.Assets; /// /// A concrete class for . /// [PluginInterface] [ServiceManager.BlockingEarlyLoadedService( "Ensuring that it is worth continuing loading Dalamud, by checking if all required assets are properly available.")] #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 internal sealed class DalamudAssetManager : IInternalDisposableService, IDalamudAssetManager { private const int DownloadAttemptCount = 10; private const int RenameAttemptCount = 10; private readonly object syncRoot = new(); private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); private readonly Dictionary?> fileStreams; private readonly Dictionary?> textureWraps; private readonly Dalamud dalamud; private readonly HappyHttpClient httpClient; private readonly string localSourceDirectory; private readonly CancellationTokenSource cancellationTokenSource; private bool isDisposed; [ServiceManager.ServiceConstructor] private DalamudAssetManager( Dalamud dalamud, HappyHttpClient httpClient, ServiceManager.RegisterStartupBlockerDelegate registerStartupBlocker) { this.dalamud = dalamud; this.httpClient = httpClient; this.localSourceDirectory = Path.Combine(this.dalamud.AssetDirectory.FullName, "..", "local"); Directory.CreateDirectory(this.localSourceDirectory); this.scopedFinalizer.Add(this.cancellationTokenSource = new()); this.fileStreams = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); this.textureWraps = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); // Block until all the required assets to be ready. var loadTimings = Timings.Start("DAM LoadAll"); registerStartupBlocker( Task.WhenAll( Enum.GetValues() .Where(x => x is not DalamudAsset.Empty4X4) .Where(x => x.GetAssetAttribute()?.Required is true) .Select(this.CreateStreamAsync) .Select(x => x.ToContentDisposedTask())) .ContinueWith( r => { loadTimings.Dispose(); return r; }) .Unwrap(), "Prevent Dalamud from loading more stuff, until we've ensured that all required assets are available."); Task.WhenAll( Enum.GetValues() .Where(x => x is not DalamudAsset.Empty4X4) .Where(x => x.GetAssetAttribute()?.Required is false) .Select(this.CreateStreamAsync) .Select(x => x.ToContentDisposedTask(true))) .ContinueWith(r => Log.Verbose($"Optional assets load state: {r}")); } /// public IDalamudTextureWrap Empty4X4 => this.GetDalamudTextureWrap(DalamudAsset.Empty4X4); /// public IDalamudTextureWrap White4X4 => this.GetDalamudTextureWrap(DalamudAsset.White4X4); /// void IInternalDisposableService.DisposeService() { lock (this.syncRoot) { if (this.isDisposed) return; this.isDisposed = true; } this.cancellationTokenSource.Cancel(); Task.WaitAll( Array.Empty() .Concat(this.fileStreams.Values) .Concat(this.textureWraps.Values) .Where(x => x is not null) .Select(x => x.ContinueWith(r => { _ = r.Exception; })) .ToArray()); this.scopedFinalizer.Dispose(); } /// [Pure] public bool IsStreamImmediatelyAvailable(DalamudAsset asset) => asset.GetAssetAttribute()?.Data is not null || this.fileStreams[asset]?.IsCompletedSuccessfully is true; /// [Pure] public Stream CreateStream(DalamudAsset asset) { var s = this.CreateStreamAsync(asset); s.Wait(); if (s.IsCompletedSuccessfully) return s.Result; if (s.Exception is not null) throw new AggregateException(s.Exception.InnerExceptions); throw new OperationCanceledException(); } /// [Pure] public Task CreateStreamAsync(DalamudAsset asset) { if (asset.GetAssetAttribute() is { Data: { } rawData }) return Task.FromResult(new MemoryStream(rawData, false)); Task task; lock (this.syncRoot) { if (this.isDisposed) throw new ObjectDisposedException(nameof(DalamudAssetManager)); task = this.fileStreams[asset] ??= CreateInnerAsync(); } return this.TransformImmediate( task, x => (Stream)new FileStream( x.Name, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan)); async Task CreateInnerAsync() { string path; List exceptions = null; foreach (var name in asset.GetAttributes().Select(x => x.FileName)) { if (!File.Exists(path = Path.Combine(this.dalamud.AssetDirectory.FullName, name))) continue; try { return File.OpenRead(path); } catch (Exception e) when (e is not OperationCanceledException) { exceptions ??= new(); exceptions.Add(e); } } if (File.Exists(path = Path.Combine(this.localSourceDirectory, asset.ToString()))) { try { return File.OpenRead(path); } catch (Exception e) when (e is not OperationCanceledException) { exceptions ??= new(); exceptions.Add(e); } } var tempPath = $"{path}.{Environment.ProcessId:x}.{Environment.CurrentManagedThreadId:x}"; try { for (var i = 0; i < DownloadAttemptCount; i++) { var attemptedAny = false; foreach (var url in asset.GetAttributes()) { Log.Information("[{who}] {asset}: Trying {url}", nameof(DalamudAssetManager), asset, url); attemptedAny = true; try { await using (var tempPathStream = File.Open(tempPath, FileMode.Create, FileAccess.Write)) { await url.DownloadAsync( this.httpClient.SharedHttpClient, tempPathStream, this.cancellationTokenSource.Token); } for (var j = RenameAttemptCount; ; j--) { try { File.Move(tempPath, path); } catch (IOException ioe) { if (j == 0) throw; Log.Warning( ioe, "[{who}] {asset}: Renaming failed; trying again {n} more times", nameof(DalamudAssetManager), asset, j); await Task.Delay(1000, this.cancellationTokenSource.Token); continue; } return File.OpenRead(path); } } catch (Exception e) when (e is not OperationCanceledException) { Log.Error(e, "[{who}] {asset}: Failed {url}", nameof(DalamudAssetManager), asset, url); } } if (!attemptedAny) throw new FileNotFoundException($"Failed to find the asset {asset}.", asset.ToString()); // Wait up to 5 minutes var delay = Math.Min(300, (1 << i) * 1000); Log.Error( "[{who}] {asset}: Failed to download. Trying again in {sec} seconds...", nameof(DalamudAssetManager), asset, delay); await Task.Delay(delay * 1000, this.cancellationTokenSource.Token); } throw new FileNotFoundException($"Failed to load the asset {asset}.", asset.ToString()); } catch (Exception e) when (e is not OperationCanceledException) { exceptions ??= new(); exceptions.Add(e); try { File.Delete(tempPath); } catch { // don't care } } throw new AggregateException(exceptions); } } /// [Pure] public IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset) => this.GetDalamudTextureWrapAsync(asset).Result; /// [Pure] [return: NotNullIfNotNull(nameof(defaultWrap))] public IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap) { var task = this.GetDalamudTextureWrapAsync(asset); return task.IsCompletedSuccessfully ? task.Result : defaultWrap; } /// [Pure] public Task GetDalamudTextureWrapAsync(DalamudAsset asset) { var purpose = asset.GetPurpose(); if (purpose is not DalamudAssetPurpose.TextureFromPng and not DalamudAssetPurpose.TextureFromRaw) throw new ArgumentOutOfRangeException(nameof(asset), asset, "The asset cannot be taken as a Texture2D."); Task task; lock (this.syncRoot) { if (this.isDisposed) throw new ObjectDisposedException(nameof(DalamudAssetManager)); task = this.textureWraps[asset] ??= CreateInnerAsync(); } return task; async Task CreateInnerAsync() { var buf = Array.Empty(); try { var tm = await Service.GetAsync(); await using var stream = await this.CreateStreamAsync(asset); var length = checked((int)stream.Length); buf = ArrayPool.Shared.Rent(length); stream.ReadExactly(buf, 0, length); var name = $"{nameof(DalamudAsset)}[{Enum.GetName(asset)}]"; var image = purpose switch { DalamudAssetPurpose.TextureFromPng => await tm.CreateFromImageAsync(buf, name), DalamudAssetPurpose.TextureFromRaw => asset.GetAttribute() is { } raw ? await tm.CreateFromRawAsync(raw.Specification, buf, name) : throw new InvalidOperationException( "TextureFromRaw must accompany a DalamudAssetRawTextureAttribute."), _ => null, }; var disposeDeferred = this.scopedFinalizer.Add(image) ?? throw new InvalidOperationException("Something went wrong very badly"); return new DisposeSuppressingTextureWrap(disposeDeferred); } catch (Exception e) { Log.Error(e, "[{name}] Failed to load {asset}.", nameof(DalamudAssetManager), asset); throw; } finally { ArrayPool.Shared.Return(buf); } } } private Task TransformImmediate(Task task, Func transformer) { if (task.IsCompletedSuccessfully) return Task.FromResult(transformer(task.Result)); if (task.Exception is { } exc) return Task.FromException(exc); return task.ContinueWith(_ => this.TransformImmediate(task, transformer)).Unwrap(); } }