Add DalamudAssetManager

This commit is contained in:
Soreepeong 2023-11-21 13:49:23 +09:00 committed by goat
parent 01153a2480
commit a72f407357
17 changed files with 867 additions and 179 deletions

View file

@ -0,0 +1,36 @@
namespace Dalamud.Storage.Assets;
/// <summary>
/// Stores the basic information of a Dalamud asset.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
internal class DalamudAssetAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="DalamudAssetAttribute"/> class.
/// </summary>
/// <param name="purpose">The purpose.</param>
/// <param name="data">The data.</param>
/// <param name="required">Whether the asset is required.</param>
public DalamudAssetAttribute(DalamudAssetPurpose purpose, byte[]? data = null, bool required = true)
{
this.Purpose = purpose;
this.Data = data;
this.Required = required;
}
/// <summary>
/// Gets the purpose of the asset.
/// </summary>
public DalamudAssetPurpose Purpose { get; }
/// <summary>
/// Gets the data, if available.
/// </summary>
public byte[]? Data { get; }
/// <summary>
/// Gets a value indicating whether the asset is required.
/// </summary>
public bool Required { get; }
}

View file

@ -0,0 +1,17 @@
using Dalamud.Utility;
namespace Dalamud.Storage.Assets;
/// <summary>
/// Extension methods for <see cref="DalamudAsset"/>.
/// </summary>
public static class DalamudAssetExtensions
{
/// <summary>
/// Gets the purpose.
/// </summary>
/// <param name="asset">The asset.</param>
/// <returns>The purpose.</returns>
public static DalamudAssetPurpose GetPurpose(this DalamudAsset asset) =>
asset.GetAttribute<DalamudAssetAttribute>()?.Purpose ?? DalamudAssetPurpose.Empty;
}

View file

@ -0,0 +1,365 @@
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.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;
/// <summary>
/// A concrete class for <see cref="IDalamudAssetManager"/>.
/// </summary>
[PluginInterface]
[ServiceManager.BlockingEarlyLoadedService]
#pragma warning disable SA1015
[ResolveVia<IDalamudAssetManager>]
#pragma warning restore SA1015
internal sealed class DalamudAssetManager : IServiceType, IDisposable, 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<DalamudAsset, Task<FileStream>?> fileStreams;
private readonly Dictionary<DalamudAsset, Task<IDalamudTextureWrap>?> 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)
{
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<DalamudAsset>().ToDictionary(x => x, _ => (Task<FileStream>?)null);
this.textureWraps = Enum.GetValues<DalamudAsset>().ToDictionary(x => x, _ => (Task<IDalamudTextureWrap>?)null);
var loadTimings = Timings.Start("DAM LoadAll");
this.WaitForAllRequiredAssets().ContinueWith(_ => loadTimings.Dispose());
}
/// <inheritdoc/>
public IDalamudTextureWrap Empty4X4 => this.GetDalamudTextureWrap(DalamudAsset.Empty4X4);
/// <inheritdoc/>
public void Dispose()
{
lock (this.syncRoot)
{
if (this.isDisposed)
return;
this.isDisposed = true;
}
this.cancellationTokenSource.Cancel();
Task.WaitAll(
Array.Empty<Task>()
.Concat(this.fileStreams.Values)
.Concat(this.textureWraps.Values)
.Where(x => x is not null)
.ToArray());
this.scopedFinalizer.Dispose();
}
/// <summary>
/// Waits for all the required assets to be ready. Will result in a faulted task, if any of the required assets
/// has failed to load.
/// </summary>
/// <returns>The task.</returns>
[Pure]
public Task WaitForAllRequiredAssets()
{
lock (this.syncRoot)
{
return Task.WhenAll(
Enum.GetValues<DalamudAsset>()
.Where(x => x is not DalamudAsset.Empty4X4)
.Select(this.CreateStreamAsync)
.Select(x => x.ToContentDisposedTask()));
}
}
/// <inheritdoc/>
[Pure]
public bool IsStreamImmediatelyAvailable(DalamudAsset asset) =>
asset.GetAttribute<DalamudAssetAttribute>()?.Data is not null
|| this.fileStreams[asset]?.IsCompletedSuccessfully is true;
/// <inheritdoc/>
[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();
}
/// <inheritdoc/>
[Pure]
public Task<Stream> CreateStreamAsync(DalamudAsset asset)
{
if (asset.GetAttribute<DalamudAssetAttribute>() is { Data: { } rawData })
return Task.FromResult<Stream>(new MemoryStream(rawData, false));
Task<FileStream> 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<FileStream> CreateInnerAsync()
{
string path;
List<Exception?> exceptions = null;
foreach (var name in asset.GetAttributes<DalamudAssetPathAttribute>().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<DalamudAssetOnlineSourceAttribute>())
{
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);
tempPathStream.Dispose();
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);
}
}
/// <inheritdoc/>
[Pure]
public IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset) =>
ExtractResult(this.GetDalamudTextureWrapAsync(asset));
/// <inheritdoc/>
[Pure]
[return: NotNullIfNotNull(nameof(defaultWrap))]
public IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap)
{
var task = this.GetDalamudTextureWrapAsync(asset);
return task.IsCompletedSuccessfully ? task.Result : defaultWrap;
}
/// <inheritdoc/>
[Pure]
public Task<IDalamudTextureWrap> 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<IDalamudTextureWrap> task;
lock (this.syncRoot)
{
if (this.isDisposed)
throw new ObjectDisposedException(nameof(DalamudAssetManager));
task = this.textureWraps[asset] ??= CreateInnerAsync();
}
return task;
async Task<IDalamudTextureWrap> CreateInnerAsync()
{
var buf = Array.Empty<byte>();
try
{
var im = (await Service<InterfaceManager.InterfaceManagerWithScene>.GetAsync()).Manager;
await using var stream = await this.CreateStreamAsync(asset);
var length = checked((int)stream.Length);
buf = ArrayPool<byte>.Shared.Rent(length);
stream.ReadExactly(buf, 0, length);
var image = purpose switch
{
DalamudAssetPurpose.TextureFromPng => im.LoadImage(buf),
DalamudAssetPurpose.TextureFromRaw =>
asset.GetAttribute<DalamudAssetRawTextureAttribute>() is { } raw
? im.LoadImageFromDxgiFormat(buf, raw.Pitch, raw.Width, raw.Height, raw.Format)
: 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 DisposeSuppressingDalamudTextureWrap(disposeDeferred);
}
catch (Exception e)
{
Log.Error(e, "[{name}] Failed to load {asset}.", nameof(DalamudAssetManager), asset);
throw;
}
finally
{
ArrayPool<byte>.Shared.Return(buf);
}
}
}
private static T ExtractResult<T>(Task<T> t) => t.IsCompleted ? t.Result : t.GetAwaiter().GetResult();
private Task<TOut> TransformImmediate<TIn, TOut>(Task<TIn> task, Func<TIn, TOut> transformer)
{
if (task.IsCompletedSuccessfully)
return Task.FromResult(transformer(task.Result));
if (task.Exception is { } exc)
return Task.FromException<TOut>(exc);
return task.ContinueWith(_ => this.TransformImmediate(task, transformer)).Unwrap();
}
private class DisposeSuppressingDalamudTextureWrap : IDalamudTextureWrap
{
private readonly IDalamudTextureWrap innerWrap;
public DisposeSuppressingDalamudTextureWrap(IDalamudTextureWrap wrap) => this.innerWrap = wrap;
/// <inheritdoc/>
public IntPtr ImGuiHandle => this.innerWrap.ImGuiHandle;
/// <inheritdoc/>
public int Width => this.innerWrap.Width;
/// <inheritdoc/>
public int Height => this.innerWrap.Height;
/// <inheritdoc/>
public void Dispose()
{
// suppressed
}
}
}

View file

@ -0,0 +1,48 @@
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Dalamud.Storage.Assets;
/// <summary>
/// Marks that an asset can be download from online.
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)]
internal class DalamudAssetOnlineSourceAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="DalamudAssetOnlineSourceAttribute"/> class.
/// </summary>
/// <param name="url">The URL.</param>
public DalamudAssetOnlineSourceAttribute(string url)
{
this.Url = url;
}
/// <summary>
/// Gets the source URL of the file.
/// </summary>
public string Url { get; }
/// <summary>
/// Downloads to the given stream.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="stream">The stream.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The task.</returns>
public async Task DownloadAsync(HttpClient client, Stream stream, CancellationToken cancellationToken)
{
using var resp = await client.GetAsync(this.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
resp.EnsureSuccessStatusCode();
if (resp.StatusCode != HttpStatusCode.OK)
throw new NotSupportedException($"Only 200 OK is supported; got {resp.StatusCode}");
await using var readStream = await resp.Content.ReadAsStreamAsync(cancellationToken);
await readStream.CopyToAsync(stream, cancellationToken);
if (resp.Content.Headers.ContentLength is { } length && stream.Length != length)
throw new IOException($"Expected {length} bytes; got {stream.Length} bytes.");
}
}

View file

@ -0,0 +1,21 @@
using System.IO;
namespace Dalamud.Storage.Assets;
/// <summary>
/// File names to look up in Dalamud assets.
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = true)]
internal class DalamudAssetPathAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="DalamudAssetPathAttribute"/> class.
/// </summary>
/// <param name="pathComponents">The path components.</param>
public DalamudAssetPathAttribute(params string[] pathComponents) => this.FileName = Path.Join(pathComponents);
/// <summary>
/// Gets the file name.
/// </summary>
public string FileName { get; }
}

View file

@ -0,0 +1,27 @@
namespace Dalamud.Storage.Assets;
/// <summary>
/// Purposes of a Dalamud asset.
/// </summary>
public enum DalamudAssetPurpose
{
/// <summary>
/// The asset has no purpose.
/// </summary>
Empty = 0,
/// <summary>
/// The asset is a .png file, and can be purposed as a <see cref="SharpDX.Direct3D11.Texture2D"/>.
/// </summary>
TextureFromPng = 10,
/// <summary>
/// The asset is a raw texture, and can be purposed as a <see cref="SharpDX.Direct3D11.Texture2D"/>.
/// </summary>
TextureFromRaw = 1001,
/// <summary>
/// The asset is a font file.
/// </summary>
Font = 2000,
}

View file

@ -0,0 +1,45 @@
using SharpDX.DXGI;
namespace Dalamud.Storage.Assets;
/// <summary>
/// Provide raw texture data directly.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
internal class DalamudAssetRawTextureAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="DalamudAssetRawTextureAttribute"/> class.
/// </summary>
/// <param name="width">The width.</param>
/// <param name="pitch">The pitch.</param>
/// <param name="height">The height.</param>
/// <param name="format">The format.</param>
public DalamudAssetRawTextureAttribute(int width, int pitch, int height, Format format)
{
this.Width = width;
this.Pitch = pitch;
this.Height = height;
this.Format = format;
}
/// <summary>
/// Gets the width.
/// </summary>
public int Width { get; }
/// <summary>
/// Gets the pitch.
/// </summary>
public int Pitch { get; }
/// <summary>
/// Gets the height.
/// </summary>
public int Height { get; }
/// <summary>
/// Gets the format.
/// </summary>
public Format Format { get; }
}

View file

@ -0,0 +1,79 @@
using System.Diagnostics.Contracts;
using System.IO;
using System.Threading.Tasks;
using Dalamud.Interface.Internal;
namespace Dalamud.Storage.Assets;
/// <summary>
/// Holds Dalamud Assets' handles hostage, so that they do not get closed while Dalamud is running.<br />
/// Also, attempts to load optional assets.<br />
/// <br />
/// <strong>Note on <see cref="PureAttribute"/></strong><br />
/// It will help you get notified if you discard the result of functions, mostly likely because of a mistake.
/// Think of C++ [[nodiscard]]. Also, like the intended meaning of the attribute, such methods will not have
/// externally visible state changes.
/// </summary>
internal interface IDalamudAssetManager
{
/// <summary>
/// Gets the shared texture wrap for <see cref="DalamudAsset.Empty4X4"/>.
/// </summary>
IDalamudTextureWrap Empty4X4 { get; }
/// <summary>
/// Gets whether the stream for the asset is instantly available.
/// </summary>
/// <param name="asset">The asset.</param>
/// <returns>Whether the stream of an asset is immediately available.</returns>
[Pure]
bool IsStreamImmediatelyAvailable(DalamudAsset asset);
/// <summary>
/// Creates a stream backed by the specified asset, waiting as necessary.<br />
/// <strong>Call <see cref="IDisposable.Dispose"/> after use.</strong>
/// </summary>
/// <param name="asset">The asset.</param>
/// <returns>The stream.</returns>
[Pure]
Stream CreateStream(DalamudAsset asset);
/// <summary>
/// Creates a stream backed by the specified asset.<br />
/// <strong>Call <see cref="IDisposable.Dispose"/> after use.</strong>
/// </summary>
/// <param name="asset">The asset.</param>
/// <returns>The stream, wrapped inside a <see cref="Stream"/>.</returns>
[Pure]
Task<Stream> CreateStreamAsync(DalamudAsset asset);
/// <summary>
/// Gets a shared instance of <see cref="IDalamudTextureWrap"/>, after waiting as necessary.<br />
/// Calls to <see cref="IDisposable.Dispose"/> is unnecessary; they will be ignored.
/// </summary>
/// <param name="asset">The texture asset.</param>
/// <returns>The texture wrap.</returns>
[Pure]
IDalamudTextureWrap GetDalamudTextureWrap(DalamudAsset asset);
/// <summary>
/// Gets a shared instance of <see cref="IDalamudTextureWrap"/> if it is available instantly;
/// if it is not ready, returns <paramref name="defaultWrap"/>.<br />
/// Calls to <see cref="IDisposable.Dispose"/> is unnecessary; they will be ignored.
/// </summary>
/// <param name="asset">The texture asset.</param>
/// <param name="defaultWrap">The default return value, if the asset is not ready for whatever reason.</param>
/// <returns>The texture wrap.</returns>
[Pure]
IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap);
/// <summary>
/// Gets a shared instance of <see cref="IDalamudTextureWrap"/> in a <see cref="Task{T}"/>.<br />
/// Calls to <see cref="IDisposable.Dispose"/> is unnecessary; they will be ignored.
/// </summary>
/// <param name="asset">The texture asset.</param>
/// <returns>The new texture wrap, wrapped inside a <see cref="Task{T}"/>.</returns>
[Pure]
Task<IDalamudTextureWrap> GetDalamudTextureWrapAsync(DalamudAsset asset);
}