diff --git a/Dalamud.Injector/EntryPoint.cs b/Dalamud.Injector/EntryPoint.cs index feed79772..a35248062 100644 --- a/Dalamud.Injector/EntryPoint.cs +++ b/Dalamud.Injector/EntryPoint.cs @@ -380,7 +380,7 @@ namespace Dalamud.Injector startInfo.BootShowConsole = args.Contains("--console"); startInfo.BootEnableEtw = args.Contains("--etw"); startInfo.BootLogPath = GetLogPath(startInfo.LogPath, "dalamud.boot", startInfo.LogName); - startInfo.BootEnabledGameFixes = new List { "prevent_devicechange_crashes", "disable_game_openprocess_access_check", "redirect_openprocess", "backup_userdata_save", "clr_failfast_hijack", "prevent_icmphandle_crashes" }; + startInfo.BootEnabledGameFixes = new List { "prevent_devicechange_crashes", "disable_game_openprocess_access_check", "redirect_openprocess", "backup_userdata_save", "prevent_icmphandle_crashes" }; startInfo.BootDotnetOpenProcessHookMode = 0; startInfo.BootWaitMessageBox |= args.Contains("--msgbox1") ? 1 : 0; startInfo.BootWaitMessageBox |= args.Contains("--msgbox2") ? 2 : 0; diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 39c53c3cb..ac410527c 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -69,6 +69,12 @@ internal sealed class DalamudConfiguration : IServiceType /// public string LastVersion { get; set; } = null; + /// + /// Gets or sets a value indicating the last seen FTUE version. + /// Unused for now, added to prevent existing users from seeing level 0 FTUE. + /// + public int SeenFtueLevel { get; set; } = 1; + /// /// Gets or sets the last loaded Dalamud version. /// diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 12068a35c..e2da1a057 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -8,7 +8,7 @@ - 7.9.0.0 + 7.10.1.0 XIV Launcher addon framework $(DalamudVersion) $(DalamudVersion) diff --git a/Dalamud/Data/DataManager.cs b/Dalamud/Data/DataManager.cs index 407a1b0da..a4c81c7a7 100644 --- a/Dalamud/Data/DataManager.cs +++ b/Dalamud/Data/DataManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; @@ -16,9 +17,11 @@ using JetBrains.Annotations; using Lumina; using Lumina.Data; using Lumina.Data.Files; +using Lumina.Data.Parsing.Tex.Buffers; using Lumina.Excel; using Newtonsoft.Json; using Serilog; +using SharpDX.DXGI; namespace Dalamud.Data; @@ -185,15 +188,17 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager /// /// The icon ID. /// The containing the icon. - /// TODO(v9): remove in api9 in favor of GetIcon(uint iconId, bool highResolution) + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(uint iconId) => this.GetIcon(this.Language, iconId, false); /// + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(uint iconId, bool highResolution) => this.GetIcon(this.Language, iconId, highResolution); /// + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(bool isHq, uint iconId) { var type = isHq ? "hq/" : string.Empty; @@ -206,11 +211,12 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager /// The requested language. /// The icon ID. /// The containing the icon. - /// TODO(v9): remove in api9 in favor of GetIcon(ClientLanguage iconLanguage, uint iconId, bool highResolution) + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(ClientLanguage iconLanguage, uint iconId) => this.GetIcon(iconLanguage, iconId, false); /// + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(ClientLanguage iconLanguage, uint iconId, bool highResolution) { var type = iconLanguage switch @@ -231,11 +237,12 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager /// The type of the icon (e.g. 'hq' to get the HQ variant of an item icon). /// The icon ID. /// The containing the icon. - /// TODO(v9): remove in api9 in favor of GetIcon(string? type, uint iconId, bool highResolution) + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(string? type, uint iconId) => this.GetIcon(type, iconId, false); /// + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(string? type, uint iconId, bool highResolution) { var format = highResolution ? HighResolutionIconFileFormat : IconFileFormat; @@ -257,14 +264,39 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager } /// + [Obsolete("Use ITextureProvider instead")] public TexFile? GetHqIcon(uint iconId) => this.GetIcon(true, iconId); /// + [Obsolete("Use ITextureProvider instead")] + [return: NotNullIfNotNull(nameof(tex))] public TextureWrap? GetImGuiTexture(TexFile? tex) - => tex == null ? null : Service.Get().LoadImageRaw(tex.GetRgbaImageData(), tex.Header.Width, tex.Header.Height, 4); + { + if (tex is null) + return null; + var im = Service.Get(); + var buffer = tex.TextureBuffer; + var bpp = 1 << (((int)tex.Header.Format & (int)TexFile.TextureFormat.BppMask) >> + (int)TexFile.TextureFormat.BppShift); + + var (dxgiFormat, conversion) = TexFile.GetDxgiFormatFromTextureFormat(tex.Header.Format, false); + if (conversion != TexFile.DxgiFormatConversion.NoConversion || !im.SupportsDxgiFormat((Format)dxgiFormat)) + { + dxgiFormat = (int)Format.B8G8R8A8_UNorm; + buffer = buffer.Filter(0, 0, TexFile.TextureFormat.B8G8R8A8); + bpp = 32; + } + + var pitch = buffer is BlockCompressionTextureBuffer + ? Math.Max(1, (buffer.Width + 3) / 4) * 2 * bpp + : ((buffer.Width * bpp) + 7) / 8; + return im.LoadImageFromDxgiFormat(buffer.RawData, pitch, buffer.Width, buffer.Height, (Format)dxgiFormat); + } + /// + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTexture(string path) => this.GetImGuiTexture(this.GetFile(path)); @@ -274,26 +306,32 @@ public sealed class DataManager : IDisposable, IServiceType, IDataManager /// The icon ID. /// The containing the icon. /// TODO(v9): remove in api9 in favor of GetImGuiTextureIcon(uint iconId, bool highResolution) + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(uint iconId) => this.GetImGuiTexture(this.GetIcon(iconId, false)); /// + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(uint iconId, bool highResolution) => this.GetImGuiTexture(this.GetIcon(iconId, highResolution)); /// + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(bool isHq, uint iconId) => this.GetImGuiTexture(this.GetIcon(isHq, iconId)); /// + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(ClientLanguage iconLanguage, uint iconId) => this.GetImGuiTexture(this.GetIcon(iconLanguage, iconId)); /// + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(string type, uint iconId) => this.GetImGuiTexture(this.GetIcon(type, iconId)); /// + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureHqIcon(uint iconId) => this.GetImGuiTexture(this.GetHqIcon(iconId)); diff --git a/Dalamud/Game/ClientState/Objects/Types/Character.cs b/Dalamud/Game/ClientState/Objects/Types/Character.cs index cdd1515b0..ee8418362 100644 --- a/Dalamud/Game/ClientState/Objects/Types/Character.cs +++ b/Dalamud/Game/ClientState/Objects/Types/Character.cs @@ -26,52 +26,52 @@ public unsafe class Character : GameObject /// /// Gets the current HP of this Chara. /// - public uint CurrentHp => this.Struct->Health; + public uint CurrentHp => this.Struct->CharacterData.Health; /// /// Gets the maximum HP of this Chara. /// - public uint MaxHp => this.Struct->MaxHealth; + public uint MaxHp => this.Struct->CharacterData.MaxHealth; /// /// Gets the current MP of this Chara. /// - public uint CurrentMp => this.Struct->Mana; + public uint CurrentMp => this.Struct->CharacterData.Mana; /// /// Gets the maximum MP of this Chara. /// - public uint MaxMp => this.Struct->MaxMana; + public uint MaxMp => this.Struct->CharacterData.MaxMana; /// /// Gets the current GP of this Chara. /// - public uint CurrentGp => this.Struct->GatheringPoints; + public uint CurrentGp => this.Struct->CharacterData.GatheringPoints; /// /// Gets the maximum GP of this Chara. /// - public uint MaxGp => this.Struct->MaxGatheringPoints; + public uint MaxGp => this.Struct->CharacterData.MaxGatheringPoints; /// /// Gets the current CP of this Chara. /// - public uint CurrentCp => this.Struct->CraftingPoints; + public uint CurrentCp => this.Struct->CharacterData.CraftingPoints; /// /// Gets the maximum CP of this Chara. /// - public uint MaxCp => this.Struct->MaxCraftingPoints; + public uint MaxCp => this.Struct->CharacterData.MaxCraftingPoints; /// /// Gets the ClassJob of this Chara. /// - public ExcelResolver ClassJob => new(this.Struct->ClassJob); + public ExcelResolver ClassJob => new(this.Struct->CharacterData.ClassJob); /// /// Gets the level of this Chara. /// - public byte Level => this.Struct->Level; + public byte Level => this.Struct->CharacterData.Level; /// /// Gets a byte array describing the visual appearance of this Chara. @@ -97,7 +97,7 @@ public unsafe class Character : GameObject /// /// Gets the current online status of the character. /// - public ExcelResolver OnlineStatus => new(this.Struct->OnlineStatus); + public ExcelResolver OnlineStatus => new(this.Struct->CharacterData.OnlineStatus); /// /// Gets the status flags. diff --git a/Dalamud/Interface/Components/ImGuiComponents.DisabledButton.cs b/Dalamud/Interface/Components/ImGuiComponents.DisabledButton.cs index 181bbbfd7..907ad0aeb 100644 --- a/Dalamud/Interface/Components/ImGuiComponents.DisabledButton.cs +++ b/Dalamud/Interface/Components/ImGuiComponents.DisabledButton.cs @@ -25,7 +25,7 @@ public static partial class ImGuiComponents var text = icon.ToIconString(); if (id.HasValue) - text = $"{text}{id}"; + text = $"{text}##{id}"; var button = DisabledButton(text, defaultColor, activeColor, hoveredColor, alphaMult); diff --git a/Dalamud/Interface/Internal/DalamudTextureWrap.cs b/Dalamud/Interface/Internal/DalamudTextureWrap.cs index 97fb1dd0b..039873f1f 100644 --- a/Dalamud/Interface/Internal/DalamudTextureWrap.cs +++ b/Dalamud/Interface/Internal/DalamudTextureWrap.cs @@ -4,11 +4,19 @@ using ImGuiScene; namespace Dalamud.Interface.Internal; +/// +/// Base TextureWrap interface for all Dalamud-owned texture wraps. +/// Used to avoid referencing ImGuiScene. +/// +public interface IDalamudTextureWrap : TextureWrap +{ +} + /// /// Safety harness for ImGuiScene textures that will defer destruction until /// the end of the frame. /// -public class DalamudTextureWrap : TextureWrap +public class DalamudTextureWrap : IDalamudTextureWrap { private readonly TextureWrap wrappedWrap; diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 1cc54612a..6bd4a85a5 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -28,6 +28,10 @@ using ImGuiScene; using JetBrains.Annotations; using PInvoke; using Serilog; +using SharpDX; +using SharpDX.Direct3D; +using SharpDX.Direct3D11; +using SharpDX.DXGI; // general dev notes, here because it's easiest @@ -323,6 +327,62 @@ internal class InterfaceManager : IDisposable, IServiceType return null; } + /// + /// Check whether the current D3D11 Device supports the given DXGI format. + /// + /// DXGI format to check. + /// Whether it is supported. + public bool SupportsDxgiFormat(Format dxgiFormat) => this.scene is null + ? throw new InvalidOperationException("Scene isn't ready.") + : this.scene.Device.CheckFormatSupport(dxgiFormat).HasFlag(FormatSupport.Texture2D); + + /// + /// Load an image from a span of bytes of specified format. + /// + /// The data to load. + /// The pitch(stride) in bytes. + /// The width in pixels. + /// The height in pixels. + /// Format of the texture. + /// A texture, ready to use in ImGui. + public IDalamudTextureWrap LoadImageFromDxgiFormat(Span data, int pitch, int width, int height, Format dxgiFormat) + { + if (this.scene == null) + throw new InvalidOperationException("Scene isn't ready."); + + ShaderResourceView resView; + unsafe + { + fixed (void* pData = data) + { + var texDesc = new Texture2DDescription + { + Width = width, + Height = height, + MipLevels = 1, + ArraySize = 1, + Format = dxgiFormat, + SampleDescription = new(1, 0), + Usage = ResourceUsage.Immutable, + BindFlags = BindFlags.ShaderResource, + CpuAccessFlags = CpuAccessFlags.None, + OptionFlags = ResourceOptionFlags.None, + }; + + using var texture = new Texture2D(this.Device, texDesc, new DataRectangle(new(pData), pitch)); + resView = new(this.Device, texture, new() + { + Format = texDesc.Format, + Dimension = ShaderResourceViewDimension.Texture2D, + Texture2D = { MipLevels = texDesc.MipLevels }, + }); + } + } + + // no sampler for now because the ImGui implementation we copied doesn't allow for changing it + return new DalamudTextureWrap(new D3DTextureWrap(resView, width, height)); + } + #nullable restore /// @@ -624,7 +684,11 @@ internal class InterfaceManager : IDisposable, IServiceType } this.RenderImGui(); + this.DisposeTextures(); + } + private void DisposeTextures() + { if (this.deferredDisposeTextures.Count > 0) { Log.Verbose("[IM] Disposing {Count} textures", this.deferredDisposeTextures.Count); diff --git a/Dalamud/Interface/Internal/TextureManager.cs b/Dalamud/Interface/Internal/TextureManager.cs new file mode 100644 index 000000000..1bc5198e3 --- /dev/null +++ b/Dalamud/Interface/Internal/TextureManager.cs @@ -0,0 +1,641 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Numerics; + +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Services; +using ImGuiScene; +using Lumina.Data.Files; + +namespace Dalamud.Interface.Internal; + +/// +/// Service responsible for loading and disposing ImGui texture wraps. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.BlockingEarlyLoadedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class TextureManager : IDisposable, IServiceType, ITextureSubstitutionProvider +{ + private const string IconFileFormat = "ui/icon/{0:D3}000/{1}{2:D6}.tex"; + private const string HighResolutionIconFileFormat = "ui/icon/{0:D3}000/{1}{2:D6}_hr1.tex"; + + private const uint MillisecondsEvictionTime = 2000; + + private static readonly ModuleLog Log = new("TEXM"); + + private readonly Framework framework; + private readonly DataManager dataManager; + private readonly InterfaceManager im; + private readonly DalamudStartInfo startInfo; + + private readonly Dictionary activeTextures = new(); + + private TextureWrap? fallbackTextureWrap; + + /// + /// Initializes a new instance of the class. + /// + /// Framework instance. + /// DataManager instance. + /// InterfaceManager instance. + /// DalamudStartInfo instance. + [ServiceManager.ServiceConstructor] + public TextureManager(Framework framework, DataManager dataManager, InterfaceManager im, DalamudStartInfo startInfo) + { + this.framework = framework; + this.dataManager = dataManager; + this.im = im; + this.startInfo = startInfo; + + this.framework.Update += this.FrameworkOnUpdate; + + Service.GetAsync().ContinueWith(_ => this.CreateFallbackTexture()); + } + + /// + public event ITextureSubstitutionProvider.TextureDataInterceptorDelegate? InterceptTexDataLoad; + + /// + /// Get a texture handle for a specific icon. + /// + /// The ID of the icon to load. + /// Options to be considered when loading the icon. + /// + /// The language to be considered when loading the icon, if the icon has versions for multiple languages. + /// If null, default to the game's current language. + /// + /// + /// Prevent Dalamud from automatically unloading this icon to save memory. Usually does not need to be set. + /// + /// + /// Null, if the icon does not exist in the specified configuration, or a texture wrap that can be used + /// to render the icon. + /// + public TextureManagerTextureWrap? GetIcon(uint iconId, ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.HiRes, ClientLanguage? language = null, bool keepAlive = false) + { + var path = this.GetIconPath(iconId, flags, language); + return path == null ? null : this.CreateWrap(path, keepAlive); + } + + /// + /// Get a path for a specific icon's .tex file. + /// + /// The ID of the icon to look up. + /// Options to be considered when loading the icon. + /// + /// The language to be considered when loading the icon, if the icon has versions for multiple languages. + /// If null, default to the game's current language. + /// + /// + /// Null, if the icon does not exist in the specified configuration, or the path to the texture's .tex file, + /// which can be loaded via IDataManager. + /// + public string? GetIconPath(uint iconId, ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.HiRes, ClientLanguage? language = null) + { + var hiRes = flags.HasFlag(ITextureProvider.IconFlags.HiRes); + + // 1. Item + var path = FormatIconPath( + iconId, + flags.HasFlag(ITextureProvider.IconFlags.ItemHighQuality) ? "hq/" : string.Empty, + hiRes); + if (this.dataManager.FileExists(path)) + return path; + + language ??= this.startInfo.Language; + var languageFolder = language switch + { + ClientLanguage.Japanese => "ja/", + ClientLanguage.English => "en/", + ClientLanguage.German => "de/", + ClientLanguage.French => "fr/", + _ => throw new ArgumentOutOfRangeException(nameof(language), $"Unknown Language: {language}"), + }; + + // 2. Regular icon, with language, hi-res + path = FormatIconPath( + iconId, + languageFolder, + hiRes); + if (this.dataManager.FileExists(path)) + return path; + + if (hiRes) + { + // 3. Regular icon, with language, no hi-res + path = FormatIconPath( + iconId, + languageFolder, + false); + if (this.dataManager.FileExists(path)) + return path; + } + + // 4. Regular icon, without language, hi-res + path = FormatIconPath( + iconId, + null, + hiRes); + if (this.dataManager.FileExists(path)) + return path; + + // 4. Regular icon, without language, no hi-res + if (hiRes) + { + path = FormatIconPath( + iconId, + null, + false); + if (this.dataManager.FileExists(path)) + return path; + } + + return null; + } + + /// + /// Get a texture handle for the texture at the specified path. + /// You may only specify paths in the game's VFS. + /// + /// The path to the texture in the game's VFS. + /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Null, if the icon does not exist, or a texture wrap that can be used to render the texture. + public TextureManagerTextureWrap? GetTextureFromGame(string path, bool keepAlive = false) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + if (Path.IsPathRooted(path)) + throw new ArgumentException("Use GetTextureFromFile() to load textures directly from a file.", nameof(path)); + + return !this.dataManager.FileExists(path) ? null : this.CreateWrap(path, keepAlive); + } + + /// + /// Get a texture handle for the image or texture, specified by the passed FileInfo. + /// You may only specify paths on the native file system. + /// + /// This API can load .png and .tex files. + /// + /// The FileInfo describing the image or texture file. + /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Null, if the file does not exist, or a texture wrap that can be used to render the texture. + public TextureManagerTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive = false) + { + ArgumentNullException.ThrowIfNull(file); + return !file.Exists ? null : this.CreateWrap(file.FullName, keepAlive); + } + + /// + /// Get a texture handle for the specified Lumina TexFile. + /// + /// The texture to obtain a handle to. + /// A texture wrap that can be used to render the texture. + public IDalamudTextureWrap? GetTexture(TexFile file) + { + ArgumentNullException.ThrowIfNull(file); + + if (!this.im.IsReady) + throw new InvalidOperationException("Cannot create textures before scene is ready"); + +#pragma warning disable CS0618 + return (IDalamudTextureWrap)this.dataManager.GetImGuiTexture(file); +#pragma warning restore CS0618 + } + + /// + public string GetSubstitutedPath(string originalPath) + { + if (this.InterceptTexDataLoad == null) + return originalPath; + + string? interceptPath = null; + this.InterceptTexDataLoad.Invoke(originalPath, ref interceptPath); + + if (interceptPath != null) + { + Log.Verbose("Intercept: {OriginalPath} => {ReplacePath}", originalPath, interceptPath); + return interceptPath; + } + + return originalPath; + } + + /// + public void InvalidatePaths(IEnumerable paths) + { + lock (this.activeTextures) + { + foreach (var path in paths) + { + if (!this.activeTextures.TryGetValue(path, out var info) || info == null) + continue; + + info.Wrap?.Dispose(); + info.Wrap = null; + } + } + } + + /// + public void Dispose() + { + this.fallbackTextureWrap?.Dispose(); + this.framework.Update -= this.FrameworkOnUpdate; + + Log.Verbose("Disposing {Num} left behind textures."); + + foreach (var activeTexture in this.activeTextures) + { + activeTexture.Value.Wrap?.Dispose(); + } + + this.activeTextures.Clear(); + } + + /// + /// Get texture info. + /// + /// Path to the texture. + /// + /// If true, exceptions caused by texture load will not be caught. + /// If false, exceptions will be caught and a dummy texture will be returned to prevent plugins from using invalid texture handles. + /// + /// Info object storing texture metadata. + internal TextureInfo GetInfo(string path, bool rethrow = false) + { + TextureInfo? info; + lock (this.activeTextures) + { + if (!this.activeTextures.TryGetValue(path, out info)) + { + Debug.Assert(rethrow, "This should never run when getting outside of creator"); + + info = new TextureInfo(); + this.activeTextures.Add(path, info); + } + + if (info == null) + throw new Exception("null info in activeTextures"); + } + + if (info.KeepAliveCount == 0) + info.LastAccess = DateTime.UtcNow; + + if (info is { Wrap: not null }) + return info; + + if (!this.im.IsReady) + throw new InvalidOperationException("Cannot create textures before scene is ready"); + + // Substitute the path here for loading, instead of when getting the respective TextureInfo + path = this.GetSubstitutedPath(path); + + TextureWrap? wrap; + try + { + // We want to load this from the disk, probably, if the path has a root + // Not sure if this can cause issues with e.g. network drives, might have to rethink + // and add a flag instead if it does. + if (Path.IsPathRooted(path)) + { + if (Path.GetExtension(path) == ".tex") + { + // Attempt to load via Lumina + var file = this.dataManager.GameData.GetFileFromDisk(path); + wrap = this.GetTexture(file); + Log.Verbose("Texture {Path} loaded FS via Lumina", path); + } + else + { + // Attempt to load image + wrap = this.im.LoadImage(path); + Log.Verbose("Texture {Path} loaded FS via LoadImage", path); + } + } + else + { + // Load regularly from dats + var file = this.dataManager.GetFile(path); + if (file == null) + throw new Exception("Could not load TexFile from dat."); + + wrap = this.GetTexture(file); + Log.Verbose("Texture {Path} loaded from SqPack", path); + } + + if (wrap == null) + throw new Exception("Could not create texture"); + + // TODO: We could support this, but I don't think it's worth it at the moment. + var extents = new Vector2(wrap.Width, wrap.Height); + if (info.Extents != Vector2.Zero && info.Extents != extents) + Log.Warning("Texture at {Path} changed size between reloads, this is currently not supported.", path); + + info.Extents = extents; + } + catch (Exception e) + { + Log.Error(e, "Could not load texture from {Path}", path); + + // When creating the texture initially, we want to be able to pass errors back to the plugin + if (rethrow) + throw; + + // This means that the load failed due to circumstances outside of our control, + // and we can't do anything about it. Return a dummy texture so that the plugin still + // has something to draw. + wrap = this.fallbackTextureWrap; + + // Prevent divide-by-zero + if (info.Extents == Vector2.Zero) + info.Extents = Vector2.One; + } + + info.Wrap = wrap; + return info; + } + + /// + /// Notify the system about an instance of a texture wrap being disposed. + /// If required conditions are met, the texture will be unloaded at the next update. + /// + /// The path to the texture. + /// Whether or not this handle was created in keep-alive mode. + internal void NotifyTextureDisposed(string path, bool keepAlive) + { + lock (this.activeTextures) + { + if (!this.activeTextures.TryGetValue(path, out var info)) + { + Log.Warning("Disposing texture that didn't exist: {Path}", path); + return; + } + + info.RefCount--; + + if (keepAlive) + info.KeepAliveCount--; + + // Clean it up by the next update. If it's re-requested in-between, we don't reload it. + if (info.RefCount <= 0) + info.LastAccess = default; + } + } + + private static string FormatIconPath(uint iconId, string? type, bool highResolution) + { + var format = highResolution ? HighResolutionIconFileFormat : IconFileFormat; + + type ??= string.Empty; + if (type.Length > 0 && !type.EndsWith("/")) + type += "/"; + + return string.Format(format, iconId / 1000, type, iconId); + } + + private TextureManagerTextureWrap? CreateWrap(string path, bool keepAlive) + { + lock (this.activeTextures) + { + // This will create the texture. + // That's fine, it's probably used immediately and this will let the plugin catch load errors. + var info = this.GetInfo(path, rethrow: true); + + // We need to increase the refcounts here while locking the collection! + // Otherwise, if this is loaded from a task, cleanup might already try to delete it + // before it can be increased. + info.RefCount++; + + if (keepAlive) + info.KeepAliveCount++; + + return new TextureManagerTextureWrap(path, info.Extents, keepAlive, this); + } + } + + private void FrameworkOnUpdate(Framework fw) + { + lock (this.activeTextures) + { + var toRemove = new List(); + + foreach (var texInfo in this.activeTextures) + { + if (texInfo.Value.RefCount == 0) + { + Log.Verbose("Evicting {Path} since no refs", texInfo.Key); + + Debug.Assert(texInfo.Value.KeepAliveCount == 0, "texInfo.Value.KeepAliveCount == 0"); + + texInfo.Value.Wrap?.Dispose(); + texInfo.Value.Wrap = null; + toRemove.Add(texInfo.Key); + continue; + } + + if (texInfo.Value.KeepAliveCount > 0 || texInfo.Value.Wrap == null) + continue; + + if (DateTime.UtcNow - texInfo.Value.LastAccess > TimeSpan.FromMilliseconds(MillisecondsEvictionTime)) + { + Log.Verbose("Evicting {Path} since too old", texInfo.Key); + texInfo.Value.Wrap.Dispose(); + texInfo.Value.Wrap = null; + } + } + + foreach (var path in toRemove) + { + this.activeTextures.Remove(path); + } + } + } + + private void CreateFallbackTexture() + { + var fallbackTexBytes = new byte[] { 0xFF, 0x00, 0xDC, 0xFF }; + this.fallbackTextureWrap = this.im.LoadImageRaw(fallbackTexBytes, 1, 1, 4); + Debug.Assert(this.fallbackTextureWrap != null, "this.fallbackTextureWrap != null"); + } + + /// + /// Internal representation of a managed texture. + /// + internal class TextureInfo + { + /// + /// Gets or sets the actual texture wrap. May be unpopulated. + /// + public TextureWrap? Wrap { get; set; } + + /// + /// Gets or sets the time the texture was last accessed. + /// + public DateTime LastAccess { get; set; } + + /// + /// Gets or sets the number of active holders of this texture. + /// + public uint RefCount { get; set; } + + /// + /// Gets or sets the number of active holders that want this texture to stay alive forever. + /// + public uint KeepAliveCount { get; set; } + + /// + /// Gets or sets the extents of the texture. + /// + public Vector2 Extents { get; set; } + } +} + +/// +/// Plugin-scoped version of a texture manager. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class TextureManagerPluginScoped : ITextureProvider, IServiceType, IDisposable +{ + private readonly TextureManager textureManager; + + private readonly List trackedTextures = new(); + + /// + /// Initializes a new instance of the class. + /// + /// TextureManager instance. + public TextureManagerPluginScoped(TextureManager textureManager) + { + this.textureManager = textureManager; + } + + /// + public IDalamudTextureWrap? GetIcon( + uint iconId, + ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.ItemHighQuality, + ClientLanguage? language = null, + bool keepAlive = false) + { + var wrap = this.textureManager.GetIcon(iconId, flags, language, keepAlive); + if (wrap == null) + return null; + + this.trackedTextures.Add(wrap); + return wrap; + } + + /// + public string? GetIconPath(uint iconId, ITextureProvider.IconFlags flags = ITextureProvider.IconFlags.HiRes, ClientLanguage? language = null) + => this.textureManager.GetIconPath(iconId, flags, language); + + /// + public IDalamudTextureWrap? GetTextureFromGame(string path, bool keepAlive = false) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + var wrap = this.textureManager.GetTextureFromGame(path, keepAlive); + if (wrap == null) + return null; + + this.trackedTextures.Add(wrap); + return wrap; + } + + /// + public IDalamudTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive) + { + ArgumentNullException.ThrowIfNull(file); + + var wrap = this.textureManager.GetTextureFromFile(file, keepAlive); + if (wrap == null) + return null; + + this.trackedTextures.Add(wrap); + return wrap; + } + + /// + public IDalamudTextureWrap? GetTexture(TexFile file) + => this.textureManager.GetTexture(file); + + /// + public void Dispose() + { + // Dispose all leaked textures + foreach (var textureWrap in this.trackedTextures.Where(x => !x.IsDisposed)) + { + textureWrap.Dispose(); + } + } +} + +/// +/// Wrap. +/// +internal class TextureManagerTextureWrap : IDalamudTextureWrap +{ + private readonly TextureManager manager; + private readonly string path; + private readonly bool keepAlive; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the texture. + /// The extents of the texture. + /// Keep alive or not. + /// Manager that we obtained this from. + internal TextureManagerTextureWrap(string path, Vector2 extents, bool keepAlive, TextureManager manager) + { + this.path = path; + this.keepAlive = keepAlive; + this.manager = manager; + this.Width = (int)extents.X; + this.Height = (int)extents.Y; + } + + /// + public IntPtr ImGuiHandle => !this.IsDisposed ? + this.manager.GetInfo(this.path).Wrap!.ImGuiHandle : + throw new InvalidOperationException("Texture already disposed. You may not render it."); + + /// + public int Width { get; private set; } + + /// + public int Height { get; private set; } + + /// + /// Gets a value indicating whether or not this wrap has already been disposed. + /// If true, the handle may be invalid. + /// + internal bool IsDisposed { get; private set; } + + /// + public void Dispose() + { + lock (this) + { + if (!this.IsDisposed) + this.manager.NotifyTextureDisposed(this.path, this.keepAlive); + + this.IsDisposed = true; + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs index 4a0cfe21a..5ad5868c3 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/TexWidget.cs @@ -1,8 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Numerics; -using Dalamud.Data; -using Dalamud.Utility; +using Dalamud.Plugin.Services; using ImGuiNET; using ImGuiScene; using Serilog; @@ -14,13 +15,18 @@ namespace Dalamud.Interface.Internal.Windows.Data; /// internal class TexWidget : IDataWindowWidget { + private readonly List addedTextures = new(); + + private string iconId = "18"; + private bool hiRes = true; + private bool hq = false; + private bool keepAlive = false; private string inputTexPath = string.Empty; - private TextureWrap? debugTex; private Vector2 inputTexUv0 = Vector2.Zero; private Vector2 inputTexUv1 = Vector2.One; private Vector4 inputTintCol = Vector4.One; private Vector2 inputTexScale = Vector2.Zero; - + /// public DataKind DataKind { get; init; } = DataKind.Tex; @@ -36,34 +42,87 @@ internal class TexWidget : IDataWindowWidget /// public void Draw() { - var dataManager = Service.Get(); + var texManager = Service.Get(); - ImGui.InputText("Tex Path", ref this.inputTexPath, 255); - ImGui.InputFloat2("UV0", ref this.inputTexUv0); - ImGui.InputFloat2("UV1", ref this.inputTexUv1); - ImGui.InputFloat4("Tint", ref this.inputTintCol); - ImGui.InputFloat2("Scale", ref this.inputTexScale); - - if (ImGui.Button("Load Tex")) + ImGui.InputText("Icon ID", ref this.iconId, 32); + ImGui.Checkbox("HQ Item", ref this.hq); + ImGui.Checkbox("Hi-Res", ref this.hiRes); + ImGui.Checkbox("Keep alive", ref this.keepAlive); + if (ImGui.Button("Load Icon")) { try { - this.debugTex = dataManager.GetImGuiTexture(this.inputTexPath); - this.inputTexScale = new Vector2(this.debugTex?.Width ?? 0, this.debugTex?.Height ?? 0); + var flags = ITextureProvider.IconFlags.None; + if (this.hq) + flags |= ITextureProvider.IconFlags.ItemHighQuality; + + if (this.hiRes) + flags |= ITextureProvider.IconFlags.HiRes; + + this.addedTextures.Add(texManager.GetIcon(uint.Parse(this.iconId), flags, keepAlive: this.keepAlive)); } catch (Exception ex) { Log.Error(ex, "Could not load tex"); } } + + ImGui.Separator(); + ImGui.InputText("Tex Path", ref this.inputTexPath, 255); + if (ImGui.Button("Load Tex")) + { + try + { + this.addedTextures.Add(texManager.GetTextureFromGame(this.inputTexPath, this.keepAlive)); + } + catch (Exception ex) + { + Log.Error(ex, "Could not load tex"); + } + } + + if (ImGui.Button("Load File")) + { + try + { + this.addedTextures.Add(texManager.GetTextureFromFile(new FileInfo(this.inputTexPath), this.keepAlive)); + } + catch (Exception ex) + { + Log.Error(ex, "Could not load tex"); + } + } + + ImGui.Separator(); + ImGui.InputFloat2("UV0", ref this.inputTexUv0); + ImGui.InputFloat2("UV1", ref this.inputTexUv1); + ImGui.InputFloat4("Tint", ref this.inputTintCol); + ImGui.InputFloat2("Scale", ref this.inputTexScale); ImGuiHelpers.ScaledDummy(10); - if (this.debugTex != null) + TextureWrap? toRemove = null; + for (var i = 0; i < this.addedTextures.Count; i++) { - ImGui.Image(this.debugTex.ImGuiHandle, this.inputTexScale, this.inputTexUv0, this.inputTexUv1, this.inputTintCol); - ImGuiHelpers.ScaledDummy(5); - Util.ShowObject(this.debugTex); + if (ImGui.CollapsingHeader($"Tex #{i}")) + { + var tex = this.addedTextures[i]; + + var scale = new Vector2(tex.Width, tex.Height); + if (this.inputTexScale != Vector2.Zero) + scale = this.inputTexScale; + + ImGui.Image(tex.ImGuiHandle, scale, this.inputTexUv0, this.inputTexUv1, this.inputTintCol); + + if (ImGui.Button($"X##{i}")) + toRemove = tex; + } + } + + if (toRemove != null) + { + toRemove.Dispose(); + this.addedTextures.Remove(toRemove); } } } diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs index 984732509..a9ad0c21a 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Dalamud.Networking.Http; using Dalamud.Plugin.Internal; using Dalamud.Utility; +using Serilog; namespace Dalamud.Interface.Internal.Windows.PluginInstaller; @@ -44,27 +45,35 @@ internal class DalamudChangelogManager this.Changelogs = null; var dalamudChangelogs = await client.GetFromJsonAsync>(DalamudChangelogUrl); - var changelogs = dalamudChangelogs.Select(x => new DalamudChangelogEntry(x)).Cast(); + var changelogs = dalamudChangelogs.Select(x => new DalamudChangelogEntry(x)).Cast().ToList(); foreach (var plugin in this.manager.InstalledPlugins) { - if (!plugin.IsThirdParty) + if (!plugin.IsThirdParty && !plugin.IsDev) { - var pluginChangelogs = await client.GetFromJsonAsync(string.Format( - PluginChangelogUrl, - plugin.Manifest.InternalName, - plugin.Manifest.Dip17Channel)); + try + { + var pluginChangelogs = await client.GetFromJsonAsync(string.Format( + PluginChangelogUrl, + plugin.Manifest.InternalName, + plugin.Manifest.Dip17Channel)); - changelogs = changelogs.Concat(pluginChangelogs.Versions - .Where(x => x.Dip17Track == plugin.Manifest.Dip17Channel) - .Select(x => new PluginChangelogEntry(plugin, x))); + changelogs.AddRange(pluginChangelogs.Versions + .Where(x => x.Dip17Track == + plugin.Manifest.Dip17Channel) + .Select(x => new PluginChangelogEntry(plugin, x))); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to load changelog for {PluginName}", plugin.Manifest.Name); + } } else { if (plugin.Manifest.Changelog.IsNullOrWhitespace()) continue; - changelogs = changelogs.Append(new PluginChangelogEntry(plugin)); + changelogs.Add(new PluginChangelogEntry(plugin)); } } diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 4e27d50bd..b648a8204 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -569,7 +569,12 @@ internal class PluginInstallerWindow : Window, IDisposable this.filterText = selectable.Localization; lock (this.listLock) + { this.ResortPlugins(); + + // Positions of plugins within the list is likely to change + this.openPluginCollapsibles.Clear(); + } } } @@ -962,7 +967,14 @@ internal class PluginInstallerWindow : Window, IDisposable { this.dalamudChangelogRefreshTaskCts = new CancellationTokenSource(); this.dalamudChangelogRefreshTask = - Task.Run(this.dalamudChangelogManager.ReloadChangelogAsync, this.dalamudChangelogRefreshTaskCts.Token); + Task.Run(this.dalamudChangelogManager.ReloadChangelogAsync, this.dalamudChangelogRefreshTaskCts.Token) + .ContinueWith(t => + { + if (!t.IsCompletedSuccessfully) + { + Log.Error(t.Exception, "Failed to load changelogs."); + } + }); } return; @@ -2354,6 +2366,10 @@ internal class PluginInstallerWindow : Window, IDisposable var config = Service.Get(); var applicableForProfiles = plugin.Manifest.SupportsProfiles && !plugin.IsDev; + var profilesThatWantThisPlugin = profileManager.Profiles + .Where(x => x.WantsPlugin(plugin.InternalName) != null) + .ToArray(); + var isInSingleProfile = profilesThatWantThisPlugin.Length == 1; var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.InternalName); // Disable everything if the updater is running or another plugin is operating @@ -2437,6 +2453,10 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.EndPopup(); } + var inMultipleProfiles = !isDefaultPlugin && !isInSingleProfile; + var inSingleNonDefaultProfileWhichIsDisabled = + isInSingleProfile && !profilesThatWantThisPlugin.First().IsEnabled; + if (plugin.State is PluginState.UnloadError or PluginState.LoadError or PluginState.DependencyResolutionFailed && !plugin.IsDev) { ImGuiComponents.DisabledButton(FontAwesomeIcon.Frown); @@ -2444,80 +2464,77 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGui.IsItemHovered()) ImGui.SetTooltip(Locs.PluginButtonToolTip_UnloadFailed); } - else if (disabled || !isDefaultPlugin) + else if (disabled || inMultipleProfiles || inSingleNonDefaultProfileWhichIsDisabled) { ImGuiComponents.DisabledToggleButton(toggleId, isLoadedAndUnloadable); - if (!isDefaultPlugin && ImGui.IsItemHovered()) - ImGui.SetTooltip(Locs.PluginButtonToolTip_NeedsToBeInDefault); + if (inMultipleProfiles && ImGui.IsItemHovered()) + ImGui.SetTooltip(Locs.PluginButtonToolTip_NeedsToBeInSingleProfile); + else if (inSingleNonDefaultProfileWhichIsDisabled && ImGui.IsItemHovered()) + ImGui.SetTooltip(Locs.PluginButtonToolTip_SingleProfileDisabled(profilesThatWantThisPlugin.First().Name)); } else { if (ImGuiComponents.ToggleButton(toggleId, ref isLoadedAndUnloadable)) { - // TODO: We can technically let profile manager take care of unloading/loading the plugin, but we should figure out error handling first. + var applicableProfile = profilesThatWantThisPlugin.First(); + Log.Verbose("Switching {InternalName} in {Profile} to {State}", + plugin.InternalName, applicableProfile, isLoadedAndUnloadable); + + try + { + // Reload the devPlugin manifest if it's a dev plugin + // The plugin might rely on changed values in the manifest + if (plugin.IsDev) + { + plugin.ReloadManifest(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Could not reload DevPlugin manifest"); + } + + // NOTE: We don't use the profile manager to actually handle loading/unloading here, + // because that might cause us to show an error if a plugin we don't actually care about + // fails to load/unload. Instead, we just do it ourselves and then update the profile. + // There is probably a smarter way to handle this, but it's probably more code. if (!isLoadedAndUnloadable) { this.enableDisableStatus = OperationStatus.InProgress; this.loadingIndicatorKind = LoadingIndicatorKind.DisablingSingle; - Task.Run(() => + Task.Run(async () => { - if (plugin.IsDev) - { - plugin.ReloadManifest(); - } - - var unloadTask = Task.Run(() => plugin.UnloadAsync()) - .ContinueWith(this.DisplayErrorContinuation, Locs.ErrorModal_UnloadFail(plugin.Name)); - - unloadTask.Wait(); - if (!unloadTask.Result) - { - this.enableDisableStatus = OperationStatus.Complete; - return; - } - - // TODO: Work this out - Task.Run(() => profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, false, false)) - .GetAwaiter().GetResult(); - this.enableDisableStatus = OperationStatus.Complete; + await plugin.UnloadAsync(); + await applicableProfile.AddOrUpdateAsync( + plugin.Manifest.InternalName, false, false); notifications.AddNotification(Locs.Notifications_PluginDisabled(plugin.Manifest.Name), Locs.Notifications_PluginDisabledTitle, NotificationType.Success); + }).ContinueWith(t => + { + this.enableDisableStatus = OperationStatus.Complete; + this.DisplayErrorContinuation(t, Locs.ErrorModal_UnloadFail(plugin.Name)); }); } else { - var enabler = new Task(() => + async Task Enabler() { this.enableDisableStatus = OperationStatus.InProgress; this.loadingIndicatorKind = LoadingIndicatorKind.EnablingSingle; - if (plugin.IsDev) - { - plugin.ReloadManifest(); - } + await applicableProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, true, false); + await plugin.LoadAsync(PluginLoadReason.Installer); - // TODO: Work this out - Task.Run(() => profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, true, false)) - .GetAwaiter().GetResult(); + notifications.AddNotification(Locs.Notifications_PluginEnabled(plugin.Manifest.Name), Locs.Notifications_PluginEnabledTitle, NotificationType.Success); + } - var loadTask = Task.Run(() => plugin.LoadAsync(PluginLoadReason.Installer)) - .ContinueWith( - this.DisplayErrorContinuation, - Locs.ErrorModal_LoadFail(plugin.Name)); - - loadTask.Wait(); + var continuation = (Task t) => + { this.enableDisableStatus = OperationStatus.Complete; - - if (!loadTask.Result) - return; - - notifications.AddNotification( - Locs.Notifications_PluginEnabled(plugin.Manifest.Name), - Locs.Notifications_PluginEnabledTitle, - NotificationType.Success); - }); + this.DisplayErrorContinuation(t, Locs.ErrorModal_LoadFail(plugin.Name)); + }; if (availableUpdate != default && !availableUpdate.InstalledPlugin.IsDev) { @@ -2527,17 +2544,19 @@ internal class PluginInstallerWindow : Window, IDisposable if (shouldUpdate) { + // We need to update the profile right here, because PM will not enable the plugin otherwise + await applicableProfile.AddOrUpdateAsync(plugin.InternalName, true, false); await this.UpdateSinglePlugin(availableUpdate); } else { - enabler.Start(); + _ = Task.Run(Enabler).ContinueWith(continuation); } }); } else { - enabler.Start(); + _ = Task.Run(Enabler).ContinueWith(continuation); } } } @@ -2963,7 +2982,18 @@ internal class PluginInstallerWindow : Window, IDisposable break; case PluginSortKind.LastUpdate: this.pluginListAvailable.Sort((p1, p2) => p2.LastUpdate.CompareTo(p1.LastUpdate)); - this.pluginListInstalled.Sort((p1, p2) => p2.Manifest.LastUpdate.CompareTo(p1.Manifest.LastUpdate)); + this.pluginListInstalled.Sort((p1, p2) => + { + // We need to get remote manifests here, as the local manifests will have the time when the current version is installed, + // not the actual time of the last update, as the plugin may be pending an update + IPluginManifest? p2Considered = this.pluginListAvailable.FirstOrDefault(x => x.InternalName == p2.InternalName); + p2Considered ??= p2.Manifest; + + IPluginManifest? p1Considered = this.pluginListAvailable.FirstOrDefault(x => x.InternalName == p1.InternalName); + p1Considered ??= p1.Manifest; + + return p2Considered.LastUpdate.CompareTo(p1Considered.LastUpdate); + }); break; case PluginSortKind.NewOrNot: this.pluginListAvailable.Sort((p1, p2) => this.WasPluginSeen(p1.InternalName) @@ -3236,6 +3266,10 @@ internal class PluginInstallerWindow : Window, IDisposable public static string PluginButtonToolTip_UnloadFailed => Loc.Localize("InstallerLoadUnloadFailedTooltip", "Plugin load/unload failed, please restart your game and try again."); public static string PluginButtonToolTip_NeedsToBeInDefault => Loc.Localize("InstallerUnloadNeedsToBeInDefault", "This plugin is in one or more collections. If you want to enable or disable it, please do so by enabling or disabling the collections it is in.\nIf you want to manage it manually, remove it from all collections."); + + public static string PluginButtonToolTip_NeedsToBeInSingleProfile => Loc.Localize("InstallerUnloadNeedsToBeInSingleProfile", "This plugin is in more than one collection. If you want to enable or disable it, please do so by enabling or disabling the collections it is in.\nIf you want to manage it here, make sure it is only in a single collection."); + + public static string PluginButtonToolTip_SingleProfileDisabled(string name) => Loc.Localize("InstallerSingleProfileDisabled", "The collection '{0}' which contains this plugin is disabled.\nPlease enable it in the collections manager to toggle the plugin individually.").Format(name); #endregion diff --git a/Dalamud/IoC/Internal/ServiceContainer.cs b/Dalamud/IoC/Internal/ServiceContainer.cs index feac634f3..db748303e 100644 --- a/Dalamud/IoC/Internal/ServiceContainer.cs +++ b/Dalamud/IoC/Internal/ServiceContainer.cs @@ -12,6 +12,9 @@ namespace Dalamud.IoC.Internal; /// /// A simple singleton-only IOC container that provides (optional) version-based dependency resolution. +/// +/// This is only used to resolve dependencies for plugins. +/// Dalamud services are constructed via Service{T}.ConstructObject at the moment. /// internal class ServiceContainer : IServiceProvider, IServiceType { @@ -31,7 +34,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType /// Register a singleton object of any type into the current IOC container. /// /// The existing instance to register in the container. - /// The interface to register. + /// The type to register. public void RegisterSingleton(Task instance) { if (instance == null) @@ -40,19 +43,27 @@ internal class ServiceContainer : IServiceProvider, IServiceType } this.instances[typeof(T)] = new(instance.ContinueWith(x => new WeakReference(x.Result)), typeof(T)); + this.RegisterInterfaces(typeof(T)); + } - var resolveViaTypes = typeof(T) - .GetCustomAttributes() - .OfType() - .Select(x => x.GetType().GetGenericArguments().First()); + /// + /// Register the interfaces that can resolve this type. + /// + /// The type to register. + public void RegisterInterfaces(Type type) + { + var resolveViaTypes = type + .GetCustomAttributes() + .OfType() + .Select(x => x.GetType().GetGenericArguments().First()); foreach (var resolvableType in resolveViaTypes) { - Log.Verbose("=> {InterfaceName} provides for {TName}", resolvableType.FullName ?? "???", typeof(T).FullName ?? "???"); + Log.Verbose("=> {InterfaceName} provides for {TName}", resolvableType.FullName ?? "???", type.FullName ?? "???"); Debug.Assert(!this.interfaceToTypeMap.ContainsKey(resolvableType), "A service already implements this interface, this is not allowed"); - Debug.Assert(typeof(T).IsAssignableTo(resolvableType), "Service does not inherit from indicated ResolveVia type"); + Debug.Assert(type.IsAssignableTo(resolvableType), "Service does not inherit from indicated ResolveVia type"); - this.interfaceToTypeMap[resolvableType] = typeof(T); + this.interfaceToTypeMap[resolvableType] = type; } } @@ -95,18 +106,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType parameters .Select(async p => { - if (p.parameterType.GetCustomAttribute() != null) - { - if (scopeImpl == null) - { - Log.Error("Failed to create {TypeName}, depends on scoped service but no scope", objectType.FullName!); - return null; - } - - return await scopeImpl.CreatePrivateScopedObject(p.parameterType, scopedObjects); - } - - var service = await this.GetService(p.parameterType, scopedObjects); + var service = await this.GetService(p.parameterType, scopeImpl, scopedObjects); if (service == null) { @@ -168,22 +168,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType foreach (var prop in props) { - object service = null; - - if (prop.propertyInfo.PropertyType.GetCustomAttribute() != null) - { - if (scopeImpl == null) - { - Log.Error("Failed to create {TypeName}, depends on scoped service but no scope", objectType.FullName!); - } - else - { - service = await scopeImpl.CreatePrivateScopedObject(prop.propertyInfo.PropertyType, publicScopes); - } - } - - service ??= await this.GetService(prop.propertyInfo.PropertyType, publicScopes); - + var service = await this.GetService(prop.propertyInfo.PropertyType, scopeImpl, publicScopes); if (service == null) { Log.Error("Requested service type {TypeName} was not available (null)", prop.propertyInfo.PropertyType.FullName!); @@ -203,7 +188,7 @@ internal class ServiceContainer : IServiceProvider, IServiceType public IServiceScope GetScope() => new ServiceScopeImpl(this); /// - object? IServiceProvider.GetService(Type serviceType) => this.GetService(serviceType); + object? IServiceProvider.GetService(Type serviceType) => this.GetSingletonService(serviceType); private static bool CheckInterfaceVersion(RequiredVersionAttribute? requiredVersion, Type parameterType) { @@ -228,9 +213,23 @@ internal class ServiceContainer : IServiceProvider, IServiceType return false; } - private async Task GetService(Type serviceType, object[] scopedObjects) + private async Task GetService(Type serviceType, ServiceScopeImpl? scope, object[] scopedObjects) { - var singletonService = await this.GetService(serviceType); + if (this.interfaceToTypeMap.TryGetValue(serviceType, out var implementingType)) + serviceType = implementingType; + + if (serviceType.GetCustomAttribute() != null) + { + if (scope == null) + { + Log.Error("Failed to create {TypeName}, is scoped but no scope provided", serviceType.FullName!); + return null; + } + + return await scope.CreatePrivateScopedObject(serviceType, scopedObjects); + } + + var singletonService = await this.GetSingletonService(serviceType, false); if (singletonService != null) { return singletonService; @@ -246,9 +245,9 @@ internal class ServiceContainer : IServiceProvider, IServiceType return scoped; } - private async Task GetService(Type serviceType) + private async Task GetSingletonService(Type serviceType, bool tryGetInterface = true) { - if (this.interfaceToTypeMap.TryGetValue(serviceType, out var implementingType)) + if (tryGetInterface && this.interfaceToTypeMap.TryGetValue(serviceType, out var implementingType)) serviceType = implementingType; if (!this.instances.TryGetValue(serviceType, out var service)) @@ -285,13 +284,24 @@ internal class ServiceContainer : IServiceProvider, IServiceType private bool ValidateCtor(ConstructorInfo ctor, Type[] types) { + bool IsTypeValid(Type type) + { + var contains = types.Any(x => x.IsAssignableTo(type)); + + // Scoped services are created on-demand + return contains || type.GetCustomAttribute() != null; + } + var parameters = ctor.GetParameters(); foreach (var parameter in parameters) { - var contains = types.Any(x => x.IsAssignableTo(parameter.ParameterType)); + var valid = IsTypeValid(parameter.ParameterType); + + // If this service is provided by an interface + if (!valid && this.interfaceToTypeMap.TryGetValue(parameter.ParameterType, out var implementationType)) + valid = IsTypeValid(implementationType); - // Scoped services are created on-demand - if (!contains && parameter.ParameterType.GetCustomAttribute() == null) + if (!valid) { Log.Error("Failed to validate {TypeName}, unable to find any services that satisfy the type", parameter.ParameterType.FullName!); return false; diff --git a/Dalamud/Logging/Internal/ModuleLog.cs b/Dalamud/Logging/Internal/ModuleLog.cs index d93730f36..c6c66e81a 100644 --- a/Dalamud/Logging/Internal/ModuleLog.cs +++ b/Dalamud/Logging/Internal/ModuleLog.cs @@ -108,7 +108,7 @@ public class ModuleLog /// The exception that caused the error. /// The message template. /// Values to log. - public void Error(Exception exception, string messageTemplate, params object[] values) + public void Error(Exception? exception, string messageTemplate, params object[] values) => this.WriteLog(LogEventLevel.Error, messageTemplate, exception, values); /// diff --git a/Dalamud/Plugin/Internal/Profiles/Profile.cs b/Dalamud/Plugin/Internal/Profiles/Profile.cs index 61d521e89..ac46d9153 100644 --- a/Dalamud/Plugin/Internal/Profiles/Profile.cs +++ b/Dalamud/Plugin/Internal/Profiles/Profile.cs @@ -232,4 +232,7 @@ internal class Profile if (apply) await this.manager.ApplyAllWantStatesAsync(); } + + /// + public override string ToString() => $"{this.Guid} ({this.Name})"; } diff --git a/Dalamud/Plugin/Services/IDataManager.cs b/Dalamud/Plugin/Services/IDataManager.cs index fa8c5bf43..ff9b40605 100644 --- a/Dalamud/Plugin/Services/IDataManager.cs +++ b/Dalamud/Plugin/Services/IDataManager.cs @@ -1,4 +1,6 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using ImGuiScene; using Lumina; @@ -91,6 +93,7 @@ public interface IDataManager /// The icon ID. /// Return high resolution version. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(uint iconId, bool highResolution = false); /// @@ -100,6 +103,7 @@ public interface IDataManager /// The icon ID. /// Return high resolution version. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(ClientLanguage iconLanguage, uint iconId, bool highResolution = false); /// @@ -109,6 +113,7 @@ public interface IDataManager /// The icon ID. /// Return high resolution version. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(string? type, uint iconId, bool highResolution = false); /// @@ -117,6 +122,7 @@ public interface IDataManager /// The icon ID. /// Return the high resolution version. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(uint iconId, bool highResolution = false); /// @@ -125,6 +131,7 @@ public interface IDataManager /// A value indicating whether the icon should be HQ. /// The icon ID. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TexFile? GetIcon(bool isHq, uint iconId); /// @@ -132,6 +139,7 @@ public interface IDataManager /// /// The icon ID. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TexFile? GetHqIcon(uint iconId); /// @@ -139,6 +147,8 @@ public interface IDataManager /// /// The Lumina . /// A that can be used to draw the texture. + [Obsolete("Use ITextureProvider instead")] + [return: NotNullIfNotNull(nameof(tex))] public TextureWrap? GetImGuiTexture(TexFile? tex); /// @@ -146,6 +156,7 @@ public interface IDataManager /// /// The internal path to the texture. /// A that can be used to draw the texture. + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTexture(string path); /// @@ -154,6 +165,7 @@ public interface IDataManager /// A value indicating whether the icon should be HQ. /// The icon ID. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(bool isHq, uint iconId); /// @@ -162,6 +174,7 @@ public interface IDataManager /// The requested language. /// The icon ID. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(ClientLanguage iconLanguage, uint iconId); /// @@ -170,6 +183,7 @@ public interface IDataManager /// The type of the icon (e.g. 'hq' to get the HQ variant of an item icon). /// The icon ID. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureIcon(string type, uint iconId); /// @@ -177,5 +191,6 @@ public interface IDataManager /// /// The icon ID. /// The containing the icon. + [Obsolete("Use ITextureProvider instead")] public TextureWrap? GetImGuiTextureHqIcon(uint iconId); } diff --git a/Dalamud/Plugin/Services/ITextureProvider.cs b/Dalamud/Plugin/Services/ITextureProvider.cs new file mode 100644 index 000000000..091b2ed67 --- /dev/null +++ b/Dalamud/Plugin/Services/ITextureProvider.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; + +using Dalamud.Interface.Internal; +using Lumina.Data.Files; + +namespace Dalamud.Plugin.Services; + +/// +/// Service that grants you access to textures you may render via ImGui. +/// +public interface ITextureProvider +{ + /// + /// Flags describing the icon you wish to receive. + /// + [Flags] + public enum IconFlags + { + /// + /// Low-resolution, standard quality icon. + /// + None = 0, + + /// + /// If this icon is an item icon, and it has a high-quality variant, receive the high-quality version. + /// Null if the item does not have a high-quality variant. + /// + ItemHighQuality = 1 << 0, + + /// + /// Get the hi-resolution version of the icon, if it exists. + /// + HiRes = 1 << 1, + } + + /// + /// Get a texture handle for a specific icon. + /// + /// The ID of the icon to load. + /// Options to be considered when loading the icon. + /// + /// The language to be considered when loading the icon, if the icon has versions for multiple languages. + /// If null, default to the game's current language. + /// + /// + /// Prevent Dalamud from automatically unloading this icon to save memory. Usually does not need to be set. + /// + /// + /// Null, if the icon does not exist in the specified configuration, or a texture wrap that can be used + /// to render the icon. + /// + public IDalamudTextureWrap? GetIcon(uint iconId, IconFlags flags = IconFlags.HiRes, ClientLanguage? language = null, bool keepAlive = false); + + /// + /// Get a path for a specific icon's .tex file. + /// + /// The ID of the icon to look up. + /// Options to be considered when loading the icon. + /// + /// The language to be considered when loading the icon, if the icon has versions for multiple languages. + /// If null, default to the game's current language. + /// + /// + /// Null, if the icon does not exist in the specified configuration, or the path to the texture's .tex file, + /// which can be loaded via IDataManager. + /// + public string? GetIconPath(uint iconId, IconFlags flags = IconFlags.HiRes, ClientLanguage? language = null); + + /// + /// Get a texture handle for the texture at the specified path. + /// You may only specify paths in the game's VFS. + /// + /// The path to the texture in the game's VFS. + /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Null, if the icon does not exist, or a texture wrap that can be used to render the texture. + public IDalamudTextureWrap? GetTextureFromGame(string path, bool keepAlive = false); + + /// + /// Get a texture handle for the image or texture, specified by the passed FileInfo. + /// You may only specify paths on the native file system. + /// + /// This API can load .png and .tex files. + /// + /// The FileInfo describing the image or texture file. + /// Prevent Dalamud from automatically unloading this texture to save memory. Usually does not need to be set. + /// Null, if the file does not exist, or a texture wrap that can be used to render the texture. + public IDalamudTextureWrap? GetTextureFromFile(FileInfo file, bool keepAlive = false); + + /// + /// Get a texture handle for the specified Lumina TexFile. + /// + /// The texture to obtain a handle to. + /// A texture wrap that can be used to render the texture. + public IDalamudTextureWrap GetTexture(TexFile file); +} diff --git a/Dalamud/Plugin/Services/ITextureSubstitutionProvider.cs b/Dalamud/Plugin/Services/ITextureSubstitutionProvider.cs new file mode 100644 index 000000000..3ddd7d13e --- /dev/null +++ b/Dalamud/Plugin/Services/ITextureSubstitutionProvider.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace Dalamud.Plugin.Services; + +/// +/// Service that grants you the ability to replace texture data that is to be loaded by Dalamud. +/// +public interface ITextureSubstitutionProvider +{ + /// + /// Delegate describing a function that may be used to intercept and replace texture data. + /// The path assigned may point to another texture inside the game's dats, or a .tex file or image on the disk. + /// + /// The path to the texture that is to be loaded. + /// The path that should be loaded instead. + public delegate void TextureDataInterceptorDelegate(string path, ref string? replacementPath); + + /// + /// Event that will be called once Dalamud wants to load texture data. + /// + public event TextureDataInterceptorDelegate? InterceptTexDataLoad; + + /// + /// Get a path that may be substituted by a subscriber to ITextureSubstitutionProvider. + /// + /// The original path to substitute. + /// The original path, if no subscriber is registered or there is no substitution, or the substituted path. + public string GetSubstitutedPath(string originalPath); + + /// + /// Notify Dalamud about substitution status for files at the specified VFS paths changing. + /// You should call this with all paths that were either previously substituted and are no longer, + /// and paths that are newly substituted. + /// + /// The paths with a changed substitution status. + public void InvalidatePaths(IEnumerable paths); +} diff --git a/Dalamud/ServiceManager.cs b/Dalamud/ServiceManager.cs index 41ffe33ca..d1c1002bd 100644 --- a/Dalamud/ServiceManager.cs +++ b/Dalamud/ServiceManager.cs @@ -132,11 +132,20 @@ internal static class ServiceManager var dependencyServicesMap = new Dictionary>(); var getAsyncTaskMap = new Dictionary(); + var serviceContainer = Service.Get(); + foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes()) { var serviceKind = serviceType.GetServiceKind(); - if (serviceKind is ServiceKind.None or ServiceKind.ScopedService) + if (serviceKind is ServiceKind.None) continue; + + // Scoped service do not go through Service, so we must let ServiceContainer know what their interfaces map to + if (serviceKind is ServiceKind.ScopedService) + { + serviceContainer.RegisterInterfaces(serviceType); + continue; + } Debug.Assert( !serviceKind.HasFlag(ServiceKind.ManualService) && !serviceKind.HasFlag(ServiceKind.ScopedService), diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index 8962a47b9..9b2eab0f2 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 8962a47b95f96bec53e58680bd9d1e7f38610d40 +Subproject commit 9b2eab0f212030c062427b307b96118881d36b99