diff --git a/Dalamud.CorePlugin/PluginImpl.cs b/Dalamud.CorePlugin/PluginImpl.cs index ef99f6def..cb9b4368a 100644 --- a/Dalamud.CorePlugin/PluginImpl.cs +++ b/Dalamud.CorePlugin/PluginImpl.cs @@ -69,6 +69,10 @@ namespace Dalamud.CorePlugin this.Interface.UiBuilder.Draw += this.OnDraw; this.Interface.UiBuilder.OpenConfigUi += this.OnOpenConfigUi; this.Interface.UiBuilder.OpenMainUi += this.OnOpenMainUi; + this.Interface.UiBuilder.DefaultFontHandle.ImFontChanged += (fc, _) => + { + Log.Information($"CorePlugin : DefaultFontHandle.ImFontChanged called {fc}"); + }; Service.Get().AddHandler("/coreplug", new(this.OnCommand) { HelpMessage = "Access the plugin." }); diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 76c8f3603..66c2745c5 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -148,12 +148,9 @@ internal sealed class DalamudConfiguration : IServiceType, IDisposable public bool UseAxisFontsFromGame { get; set; } = false; /// - /// Gets or sets the gamma value to apply for Dalamud fonts. Effects text thickness. - /// - /// Before gamma is applied... - /// * ...TTF fonts loaded with stb or FreeType are in linear space. - /// * ...the game's prebaked AXIS fonts are in gamma space with gamma value of 1.4. + /// Gets or sets the gamma value to apply for Dalamud fonts. Do not use. /// + [Obsolete("It happens that nobody touched this setting", true)] public float FontGammaLevel { get; set; } = 1.4f; /// diff --git a/Dalamud/Dalamud.cs b/Dalamud/Dalamud.cs index 4ab617d0a..8c858ce7c 100644 --- a/Dalamud/Dalamud.cs +++ b/Dalamud/Dalamud.cs @@ -117,6 +117,14 @@ internal sealed class Dalamud : IServiceType } }); } + + this.DefaultExceptionFilter = NativeFunctions.SetUnhandledExceptionFilter(nint.Zero); + NativeFunctions.SetUnhandledExceptionFilter(this.DefaultExceptionFilter); + Log.Debug($"SE default exception filter at {this.DefaultExceptionFilter.ToInt64():X}"); + + var debugSig = "40 55 53 56 48 8D AC 24 ?? ?? ?? ?? B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 2B E0 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 85 ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ??"; + this.DebugExceptionFilter = Service.Get().ScanText(debugSig); + Log.Debug($"SE debug exception filter at {this.DebugExceptionFilter.ToInt64():X}"); } /// @@ -128,7 +136,17 @@ internal sealed class Dalamud : IServiceType /// Gets location of stored assets. /// internal DirectoryInfo AssetDirectory => new(this.StartInfo.AssetDirectory!); - + + /// + /// Gets the in-game default exception filter. + /// + private nint DefaultExceptionFilter { get; } + + /// + /// Gets the in-game debug exception filter. + /// + private nint DebugExceptionFilter { get; } + /// /// Signal to the crash handler process that we should restart the game. /// @@ -191,18 +209,32 @@ internal sealed class Dalamud : IServiceType } /// - /// Replace the built-in exception handler with a debug one. + /// Replace the current exception handler with the default one. /// - internal void ReplaceExceptionHandler() - { - var releaseSig = "40 55 53 56 48 8D AC 24 ?? ?? ?? ?? B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 2B E0 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 85 ?? ?? ?? ?? 48 83 3D ?? ?? ?? ?? ??"; - var releaseFilter = Service.Get().ScanText(releaseSig); - Log.Debug($"SE debug filter at {releaseFilter.ToInt64():X}"); + internal void UseDefaultExceptionHandler() => + this.SetExceptionHandler(this.DefaultExceptionFilter); - var oldFilter = NativeFunctions.SetUnhandledExceptionFilter(releaseFilter); - Log.Debug("Reset ExceptionFilter, old: {0}", oldFilter); + /// + /// Replace the current exception handler with a debug one. + /// + internal void UseDebugExceptionHandler() => + this.SetExceptionHandler(this.DebugExceptionFilter); + + /// + /// Disable the current exception handler. + /// + internal void UseNoExceptionHandler() => + this.SetExceptionHandler(nint.Zero); + + /// + /// Helper function to set the exception handler. + /// + private void SetExceptionHandler(nint newFilter) + { + var oldFilter = NativeFunctions.SetUnhandledExceptionFilter(newFilter); + Log.Debug("Set ExceptionFilter to {0}, old: {1}", newFilter, oldFilter); } - + private void SetupClientStructsResolver(DirectoryInfo cacheDir) { using (Timings.Start("CS Resolver Init")) diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 727879c84..434e6f868 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -70,6 +70,7 @@ + all diff --git a/Dalamud/DalamudAsset.cs b/Dalamud/DalamudAsset.cs index 184193796..a7b35b196 100644 --- a/Dalamud/DalamudAsset.cs +++ b/Dalamud/DalamudAsset.cs @@ -63,41 +63,48 @@ public enum DalamudAsset [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "troubleIcon.png")] TroubleIcon = 1006, + + /// + /// : The plugin trouble icon overlay. + /// + [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] + [DalamudAssetPath("UIRes", "devPluginIcon.png")] + DevPluginIcon = 1007, /// /// : The plugin update icon overlay. /// [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "updateIcon.png")] - UpdateIcon = 1007, + UpdateIcon = 1008, /// /// : The plugin installed icon overlay. /// [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "installedIcon.png")] - InstalledIcon = 1008, + InstalledIcon = 1009, /// /// : The third party plugin icon overlay. /// [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "thirdIcon.png")] - ThirdIcon = 1009, + ThirdIcon = 1010, /// /// : The installed third party plugin icon overlay. /// [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "thirdInstalledIcon.png")] - ThirdInstalledIcon = 1010, + ThirdInstalledIcon = 1011, /// /// : The API bump explainer icon. /// [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "changelogApiBump.png")] - ChangelogApiBumpIcon = 1011, + ChangelogApiBumpIcon = 1012, /// /// : The background shade for @@ -105,7 +112,7 @@ public enum DalamudAsset /// [DalamudAsset(DalamudAssetPurpose.TextureFromPng)] [DalamudAssetPath("UIRes", "tsmShade.png")] - TitleScreenMenuShade = 1012, + TitleScreenMenuShade = 1013, /// /// : Noto Sans CJK JP Medium. diff --git a/Dalamud/Game/ClientState/Objects/Types/Character.cs b/Dalamud/Game/ClientState/Objects/Types/Character.cs index a1eb52edc..ac11bcdd0 100644 --- a/Dalamud/Game/ClientState/Objects/Types/Character.cs +++ b/Dalamud/Game/ClientState/Objects/Types/Character.cs @@ -61,6 +61,11 @@ public unsafe class Character : GameObject /// public uint MaxCp => this.Struct->CharacterData.MaxCraftingPoints; + /// + /// Gets the shield percentage of this Chara. + /// + public byte ShieldPercentage => this.Struct->CharacterData.ShieldValue; + /// /// Gets the ClassJob of this Chara. /// diff --git a/Dalamud/Interface/GameFonts/FdtFileView.cs b/Dalamud/Interface/GameFonts/FdtFileView.cs new file mode 100644 index 000000000..896a6dbb4 --- /dev/null +++ b/Dalamud/Interface/GameFonts/FdtFileView.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System.IO; + +namespace Dalamud.Interface.GameFonts; + +/// +/// Reference member view of a .fdt file data. +/// +internal readonly unsafe struct FdtFileView +{ + private readonly byte* ptr; + + /// + /// Initializes a new instance of the struct. + /// + /// Pointer to the data. + /// Length of the data. + public FdtFileView(void* ptr, int length) + { + this.ptr = (byte*)ptr; + if (length < sizeof(FdtReader.FdtHeader)) + throw new InvalidDataException("Not enough space for a FdtHeader"); + + if (length < this.FileHeader.FontTableHeaderOffset + sizeof(FdtReader.FontTableHeader)) + throw new InvalidDataException("Not enough space for a FontTableHeader"); + if (length < this.FileHeader.FontTableHeaderOffset + sizeof(FdtReader.FontTableHeader) + + (sizeof(FdtReader.FontTableEntry) * this.FontHeader.FontTableEntryCount)) + throw new InvalidDataException("Not enough space for all the FontTableEntry"); + + if (length < this.FileHeader.KerningTableHeaderOffset + sizeof(FdtReader.KerningTableHeader)) + throw new InvalidDataException("Not enough space for a KerningTableHeader"); + if (length < this.FileHeader.KerningTableHeaderOffset + sizeof(FdtReader.KerningTableHeader) + + (sizeof(FdtReader.KerningTableEntry) * this.KerningEntryCount)) + throw new InvalidDataException("Not enough space for all the KerningTableEntry"); + } + + /// + /// Gets the file header. + /// + public ref FdtReader.FdtHeader FileHeader => ref *(FdtReader.FdtHeader*)this.ptr; + + /// + /// Gets the font header. + /// + public ref FdtReader.FontTableHeader FontHeader => + ref *(FdtReader.FontTableHeader*)((nint)this.ptr + this.FileHeader.FontTableHeaderOffset); + + /// + /// Gets the glyphs. + /// + public Span Glyphs => new(this.GlyphsUnsafe, this.FontHeader.FontTableEntryCount); + + /// + /// Gets the kerning header. + /// + public ref FdtReader.KerningTableHeader KerningHeader => + ref *(FdtReader.KerningTableHeader*)((nint)this.ptr + this.FileHeader.KerningTableHeaderOffset); + + /// + /// Gets the number of kerning entries. + /// + public int KerningEntryCount => Math.Min(this.FontHeader.KerningTableEntryCount, this.KerningHeader.Count); + + /// + /// Gets the kerning entries. + /// + public Span PairAdjustments => new( + this.ptr + this.FileHeader.KerningTableHeaderOffset + sizeof(FdtReader.KerningTableHeader), + this.KerningEntryCount); + + /// + /// Gets the maximum texture index. + /// + public int MaxTextureIndex + { + get + { + var i = 0; + foreach (ref var g in this.Glyphs) + { + if (g.TextureIndex > i) + i = g.TextureIndex; + } + + return i; + } + } + + private FdtReader.FontTableEntry* GlyphsUnsafe => + (FdtReader.FontTableEntry*)(this.ptr + this.FileHeader.FontTableHeaderOffset + + sizeof(FdtReader.FontTableHeader)); + + /// + /// Finds the glyph index for the corresponding codepoint. + /// + /// Unicode codepoint (UTF-32 value). + /// Corresponding index, or a negative number according to . + public int FindGlyphIndex(int codepoint) + { + var comp = FdtReader.CodePointToUtf8Int32(codepoint); + + var glyphs = this.GlyphsUnsafe; + var lo = 0; + var hi = this.FontHeader.FontTableEntryCount - 1; + while (lo <= hi) + { + var i = (int)(((uint)hi + (uint)lo) >> 1); + switch (comp.CompareTo(glyphs[i].CharUtf8)) + { + case 0: + return i; + case > 0: + lo = i + 1; + break; + default: + hi = i - 1; + break; + } + } + + return ~lo; + } + + /// + /// Create a glyph range for use with . + /// + /// Merge two ranges into one if distance is below the value specified in this parameter. + /// Glyph ranges. + public ushort[] ToGlyphRanges(int mergeDistance = 8) + { + var glyphs = this.Glyphs; + var ranges = new List(glyphs.Length) + { + checked((ushort)glyphs[0].CharInt), + checked((ushort)glyphs[0].CharInt), + }; + + foreach (ref var glyph in glyphs[1..]) + { + var c32 = glyph.CharInt; + if (c32 >= 0x10000) + break; + + var c16 = unchecked((ushort)c32); + if (ranges[^1] + mergeDistance >= c16 && c16 > ranges[^1]) + { + ranges[^1] = c16; + } + else if (ranges[^1] + 1 < c16) + { + ranges.Add(c16); + ranges.Add(c16); + } + } + + ranges.Add(0); + return ranges.ToArray(); + } +} diff --git a/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs b/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs index dd78baf87..6e66cf19b 100644 --- a/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs +++ b/Dalamud/Interface/GameFonts/GameFontFamilyAndSize.cs @@ -3,7 +3,7 @@ namespace Dalamud.Interface.GameFonts; /// /// Enum of available game fonts in specific sizes. /// -public enum GameFontFamilyAndSize : int +public enum GameFontFamilyAndSize { /// /// Placeholder meaning unused. @@ -15,6 +15,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_96.fdt", "common/font/font{0}.tex", -1)] Axis96, /// @@ -22,6 +23,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_12.fdt", "common/font/font{0}.tex", -1)] Axis12, /// @@ -29,6 +31,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_14.fdt", "common/font/font{0}.tex", -1)] Axis14, /// @@ -36,6 +39,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_18.fdt", "common/font/font{0}.tex", -1)] Axis18, /// @@ -43,6 +47,7 @@ public enum GameFontFamilyAndSize : int /// /// Contains Japanese characters in addition to Latin characters. Used in game for the whole UI. /// + [GameFontFamilyAndSize("common/font/AXIS_36.fdt", "common/font/font{0}.tex", -4)] Axis36, /// @@ -50,6 +55,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_16.fdt", "common/font/font{0}.tex", -1)] Jupiter16, /// @@ -57,6 +63,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_20.fdt", "common/font/font{0}.tex", -1)] Jupiter20, /// @@ -64,6 +71,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_23.fdt", "common/font/font{0}.tex", -1)] Jupiter23, /// @@ -71,6 +79,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly numbers. Used in game for flying texts. /// + [GameFontFamilyAndSize("common/font/Jupiter_45.fdt", "common/font/font{0}.tex", -2)] Jupiter45, /// @@ -78,6 +87,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly ASCII range. Used in game for job names. /// + [GameFontFamilyAndSize("common/font/Jupiter_46.fdt", "common/font/font{0}.tex", -2)] Jupiter46, /// @@ -85,6 +95,7 @@ public enum GameFontFamilyAndSize : int /// /// Serif font. Contains mostly numbers. Used in game for flying texts. /// + [GameFontFamilyAndSize("common/font/Jupiter_90.fdt", "common/font/font{0}.tex", -4)] Jupiter90, /// @@ -92,6 +103,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly numbers. Used in game for HP/MP/IL stuff. /// + [GameFontFamilyAndSize("common/font/Meidinger_16.fdt", "common/font/font{0}.tex", -1)] Meidinger16, /// @@ -99,6 +111,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly numbers. Used in game for HP/MP/IL stuff. /// + [GameFontFamilyAndSize("common/font/Meidinger_20.fdt", "common/font/font{0}.tex", -1)] Meidinger20, /// @@ -106,6 +119,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly numbers. Used in game for HP/MP/IL stuff. /// + [GameFontFamilyAndSize("common/font/Meidinger_40.fdt", "common/font/font{0}.tex", -4)] Meidinger40, /// @@ -113,6 +127,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_10.fdt", "common/font/font{0}.tex", -1)] MiedingerMid10, /// @@ -120,6 +135,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_12.fdt", "common/font/font{0}.tex", -1)] MiedingerMid12, /// @@ -127,6 +143,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_14.fdt", "common/font/font{0}.tex", -1)] MiedingerMid14, /// @@ -134,6 +151,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_18.fdt", "common/font/font{0}.tex", -1)] MiedingerMid18, /// @@ -141,6 +159,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally wide. Contains mostly ASCII range. /// + [GameFontFamilyAndSize("common/font/MiedingerMid_36.fdt", "common/font/font{0}.tex", -2)] MiedingerMid36, /// @@ -148,6 +167,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_184.fdt", "common/font/font{0}.tex", -1)] TrumpGothic184, /// @@ -155,6 +175,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_23.fdt", "common/font/font{0}.tex", -1)] TrumpGothic23, /// @@ -162,6 +183,7 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_34.fdt", "common/font/font{0}.tex", -1)] TrumpGothic34, /// @@ -169,5 +191,6 @@ public enum GameFontFamilyAndSize : int /// /// Horizontally narrow. Contains mostly ASCII range. Used for addon titles. /// + [GameFontFamilyAndSize("common/font/TrumpGothic_68.fdt", "common/font/font{0}.tex", -3)] TrumpGothic68, } diff --git a/Dalamud/Interface/GameFonts/GameFontFamilyAndSizeAttribute.cs b/Dalamud/Interface/GameFonts/GameFontFamilyAndSizeAttribute.cs new file mode 100644 index 000000000..f5260e4bc --- /dev/null +++ b/Dalamud/Interface/GameFonts/GameFontFamilyAndSizeAttribute.cs @@ -0,0 +1,37 @@ +namespace Dalamud.Interface.GameFonts; + +/// +/// Marks the path for an enum value. +/// +[AttributeUsage(AttributeTargets.Field)] +internal class GameFontFamilyAndSizeAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// Inner path of the file. + /// the file path format for the relevant .tex files. + /// Horizontal offset of the corresponding font. + public GameFontFamilyAndSizeAttribute(string path, string texPathFormat, int horizontalOffset) + { + this.Path = path; + this.TexPathFormat = texPathFormat; + this.HorizontalOffset = horizontalOffset; + } + + /// + /// Gets the path. + /// + public string Path { get; } + + /// + /// Gets the file path format for the relevant .tex files.
+ /// Used for (, ). + ///
+ public string TexPathFormat { get; } + + /// + /// Gets the horizontal offset of the corresponding font. + /// + public int HorizontalOffset { get; } +} diff --git a/Dalamud/Interface/GameFonts/GameFontHandle.cs b/Dalamud/Interface/GameFonts/GameFontHandle.cs index d71e725c5..2594eea0e 100644 --- a/Dalamud/Interface/GameFonts/GameFontHandle.cs +++ b/Dalamud/Interface/GameFonts/GameFontHandle.cs @@ -1,75 +1,102 @@ -using System; using System.Numerics; +using System.Threading.Tasks; + +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Utility; using ImGuiNET; namespace Dalamud.Interface.GameFonts; /// -/// Prepare and keep game font loaded for use in OnDraw. +/// ABI-compatible wrapper for . /// -public class GameFontHandle : IDisposable +[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] +public sealed class GameFontHandle : IFontHandle { - private readonly GameFontManager manager; - private readonly GameFontStyle fontStyle; + private readonly GamePrebakedFontHandle fontHandle; + private readonly FontAtlasFactory fontAtlasFactory; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class.
+ /// Ownership of is transferred. ///
- /// GameFontManager instance. - /// Font to use. - internal GameFontHandle(GameFontManager manager, GameFontStyle font) + /// The wrapped . + /// An instance of . + internal GameFontHandle(GamePrebakedFontHandle fontHandle, FontAtlasFactory fontAtlasFactory) { - this.manager = manager; - this.fontStyle = font; + this.fontHandle = fontHandle; + this.fontAtlasFactory = fontAtlasFactory; } - /// - /// Gets the font style. - /// - public GameFontStyle Style => this.fontStyle; - - /// - /// Gets a value indicating whether this font is ready for use. - /// - public bool Available + /// + public event IFontHandle.ImFontChangedDelegate ImFontChanged { - get - { - unsafe - { - return this.manager.GetFont(this.fontStyle).GetValueOrDefault(null).NativePtr != null; - } - } + add => this.fontHandle.ImFontChanged += value; + remove => this.fontHandle.ImFontChanged -= value; } - /// - /// Gets the font. - /// - public ImFontPtr ImFont => this.manager.GetFont(this.fontStyle).Value; + /// + public Exception? LoadException => this.fontHandle.LoadException; + + /// + public bool Available => this.fontHandle.Available; /// - /// Gets the FdtReader. + /// Gets the font.
+ /// Use of this properly is safe only from the UI thread.
+ /// Use if the intended purpose of this property is .
+ /// Futures changes may make simple not enough.
+ /// If you need to access a font outside the UI thread, use . ///
- public FdtReader FdtReader => this.manager.GetFdtReader(this.fontStyle.FamilyAndSize); + [Obsolete($"Use {nameof(Push)}-{nameof(ImGui.GetFont)} or {nameof(Lock)} instead.", false)] + public ImFontPtr ImFont => this.fontHandle.LockUntilPostFrame(); /// - /// Creates a new GameFontLayoutPlan.Builder. + /// Gets the font style. Only applicable for . + /// + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] + public GameFontStyle Style => ((GamePrebakedFontHandle)this.fontHandle).FontStyle; + + /// + /// Gets the relevant .
+ ///
+ /// Only applicable for game fonts. Otherwise it will throw. + ///
+ [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] + public FdtReader FdtReader => this.fontAtlasFactory.GetFdtReader(this.Style.FamilyAndSize)!; + + /// + public void Dispose() => this.fontHandle.Dispose(); + + /// + public ILockedImFont Lock() => this.fontHandle.Lock(); + + /// + public IDisposable Push() => this.fontHandle.Push(); + + /// + public void Pop() => this.fontHandle.Pop(); + + /// + public Task WaitAsync() => this.fontHandle.WaitAsync(); + + /// + /// Creates a new .
+ ///
+ /// Only applicable for game fonts. Otherwise it will throw. ///
/// Text. /// A new builder for GameFontLayoutPlan. - public GameFontLayoutPlan.Builder LayoutBuilder(string text) - { - return new GameFontLayoutPlan.Builder(this.ImFont, this.FdtReader, text); - } - - /// - public void Dispose() => this.manager.DecreaseFontRef(this.fontStyle); + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] + public GameFontLayoutPlan.Builder LayoutBuilder(string text) => new(this.ImFont, this.FdtReader, text); /// /// Draws text. /// /// Text to draw. + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] public void Text(string text) { if (!this.Available) @@ -93,6 +120,7 @@ public class GameFontHandle : IDisposable ///
/// Color. /// Text to draw. + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] public void TextColored(Vector4 col, string text) { ImGui.PushStyleColor(ImGuiCol.Text, col); @@ -104,6 +132,7 @@ public class GameFontHandle : IDisposable /// Draws disabled text. /// /// Text to draw. + [Obsolete("If you use this, let the fact that you use this be known at Dalamud Discord.", false)] public void TextDisabled(string text) { unsafe diff --git a/Dalamud/Interface/GameFonts/GameFontManager.cs b/Dalamud/Interface/GameFonts/GameFontManager.cs deleted file mode 100644 index b3454e085..000000000 --- a/Dalamud/Interface/GameFonts/GameFontManager.cs +++ /dev/null @@ -1,507 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; - -using Dalamud.Data; -using Dalamud.Game; -using Dalamud.Interface.Internal; -using Dalamud.Interface.Utility; -using Dalamud.Utility.Timing; -using ImGuiNET; -using Lumina.Data.Files; -using Serilog; - -using static Dalamud.Interface.Utility.ImGuiHelpers; - -namespace Dalamud.Interface.GameFonts; - -/// -/// Loads game font for use in ImGui. -/// -[ServiceManager.BlockingEarlyLoadedService] -internal class GameFontManager : IServiceType -{ - private static readonly string?[] FontNames = - { - null, - "AXIS_96", "AXIS_12", "AXIS_14", "AXIS_18", "AXIS_36", - "Jupiter_16", "Jupiter_20", "Jupiter_23", "Jupiter_45", "Jupiter_46", "Jupiter_90", - "Meidinger_16", "Meidinger_20", "Meidinger_40", - "MiedingerMid_10", "MiedingerMid_12", "MiedingerMid_14", "MiedingerMid_18", "MiedingerMid_36", - "TrumpGothic_184", "TrumpGothic_23", "TrumpGothic_34", "TrumpGothic_68", - }; - - private readonly object syncRoot = new(); - - private readonly FdtReader?[] fdts; - private readonly List texturePixels; - private readonly Dictionary fonts = new(); - private readonly Dictionary fontUseCounter = new(); - private readonly Dictionary>> glyphRectIds = new(); - -#pragma warning disable CS0414 - private bool isBetweenBuildFontsAndRightAfterImGuiIoFontsBuild = false; -#pragma warning restore CS0414 - - [ServiceManager.ServiceConstructor] - private GameFontManager(DataManager dataManager) - { - using (Timings.Start("Getting fdt data")) - { - this.fdts = FontNames.Select(fontName => fontName == null ? null : new FdtReader(dataManager.GetFile($"common/font/{fontName}.fdt")!.Data)).ToArray(); - } - - using (Timings.Start("Getting texture data")) - { - var texTasks = Enumerable - .Range(1, 1 + this.fdts - .Where(x => x != null) - .Select(x => x.Glyphs.Select(y => y.TextureFileIndex).Max()) - .Max()) - .Select(x => dataManager.GetFile($"common/font/font{x}.tex")!) - .Select(x => new Task(Timings.AttachTimingHandle(() => x.ImageData!))) - .ToArray(); - foreach (var task in texTasks) - task.Start(); - this.texturePixels = texTasks.Select(x => x.GetAwaiter().GetResult()).ToList(); - } - } - - /// - /// Describe font into a string. - /// - /// Font to describe. - /// A string in a form of "FontName (NNNpt)". - public static string DescribeFont(GameFontFamilyAndSize font) - { - return font switch - { - GameFontFamilyAndSize.Undefined => "-", - GameFontFamilyAndSize.Axis96 => "AXIS (9.6pt)", - GameFontFamilyAndSize.Axis12 => "AXIS (12pt)", - GameFontFamilyAndSize.Axis14 => "AXIS (14pt)", - GameFontFamilyAndSize.Axis18 => "AXIS (18pt)", - GameFontFamilyAndSize.Axis36 => "AXIS (36pt)", - GameFontFamilyAndSize.Jupiter16 => "Jupiter (16pt)", - GameFontFamilyAndSize.Jupiter20 => "Jupiter (20pt)", - GameFontFamilyAndSize.Jupiter23 => "Jupiter (23pt)", - GameFontFamilyAndSize.Jupiter45 => "Jupiter Numeric (45pt)", - GameFontFamilyAndSize.Jupiter46 => "Jupiter (46pt)", - GameFontFamilyAndSize.Jupiter90 => "Jupiter Numeric (90pt)", - GameFontFamilyAndSize.Meidinger16 => "Meidinger Numeric (16pt)", - GameFontFamilyAndSize.Meidinger20 => "Meidinger Numeric (20pt)", - GameFontFamilyAndSize.Meidinger40 => "Meidinger Numeric (40pt)", - GameFontFamilyAndSize.MiedingerMid10 => "MiedingerMid (10pt)", - GameFontFamilyAndSize.MiedingerMid12 => "MiedingerMid (12pt)", - GameFontFamilyAndSize.MiedingerMid14 => "MiedingerMid (14pt)", - GameFontFamilyAndSize.MiedingerMid18 => "MiedingerMid (18pt)", - GameFontFamilyAndSize.MiedingerMid36 => "MiedingerMid (36pt)", - GameFontFamilyAndSize.TrumpGothic184 => "Trump Gothic (18.4pt)", - GameFontFamilyAndSize.TrumpGothic23 => "Trump Gothic (23pt)", - GameFontFamilyAndSize.TrumpGothic34 => "Trump Gothic (34pt)", - GameFontFamilyAndSize.TrumpGothic68 => "Trump Gothic (68pt)", - _ => throw new ArgumentOutOfRangeException(nameof(font), font, "Invalid argument"), - }; - } - - /// - /// Determines whether a font should be able to display most of stuff. - /// - /// Font to check. - /// True if it can. - public static bool IsGenericPurposeFont(GameFontFamilyAndSize font) - { - return font switch - { - GameFontFamilyAndSize.Axis96 => true, - GameFontFamilyAndSize.Axis12 => true, - GameFontFamilyAndSize.Axis14 => true, - GameFontFamilyAndSize.Axis18 => true, - GameFontFamilyAndSize.Axis36 => true, - _ => false, - }; - } - - /// - /// Unscales fonts after they have been rendered onto atlas. - /// - /// Font to unscale. - /// Scale factor. - /// Whether to call target.BuildLookupTable(). - public static void UnscaleFont(ImFontPtr fontPtr, float fontScale, bool rebuildLookupTable = true) - { - if (fontScale == 1) - return; - - unsafe - { - var font = fontPtr.NativePtr; - for (int i = 0, i_ = font->IndexedHotData.Size; i < i_; ++i) - { - font->IndexedHotData.Ref(i).AdvanceX /= fontScale; - font->IndexedHotData.Ref(i).OccupiedWidth /= fontScale; - } - - font->FontSize /= fontScale; - font->Ascent /= fontScale; - font->Descent /= fontScale; - if (font->ConfigData != null) - font->ConfigData->SizePixels /= fontScale; - var glyphs = (ImFontGlyphReal*)font->Glyphs.Data; - for (int i = 0, i_ = font->Glyphs.Size; i < i_; i++) - { - var glyph = &glyphs[i]; - glyph->X0 /= fontScale; - glyph->X1 /= fontScale; - glyph->Y0 /= fontScale; - glyph->Y1 /= fontScale; - glyph->AdvanceX /= fontScale; - } - - for (int i = 0, i_ = font->KerningPairs.Size; i < i_; i++) - font->KerningPairs.Ref(i).AdvanceXAdjustment /= fontScale; - for (int i = 0, i_ = font->FrequentKerningPairs.Size; i < i_; i++) - font->FrequentKerningPairs.Ref(i) /= fontScale; - } - - if (rebuildLookupTable && fontPtr.Glyphs.Size > 0) - fontPtr.BuildLookupTableNonstandard(); - } - - /// - /// Create a glyph range for use with ImGui AddFont. - /// - /// Font family and size. - /// Merge two ranges into one if distance is below the value specified in this parameter. - /// Glyph ranges. - public GCHandle ToGlyphRanges(GameFontFamilyAndSize family, int mergeDistance = 8) - { - var fdt = this.fdts[(int)family]!; - var ranges = new List(fdt.Glyphs.Count) - { - checked((ushort)fdt.Glyphs[0].CharInt), - checked((ushort)fdt.Glyphs[0].CharInt), - }; - - foreach (var glyph in fdt.Glyphs.Skip(1)) - { - var c32 = glyph.CharInt; - if (c32 >= 0x10000) - break; - - var c16 = unchecked((ushort)c32); - if (ranges[^1] + mergeDistance >= c16 && c16 > ranges[^1]) - { - ranges[^1] = c16; - } - else if (ranges[^1] + 1 < c16) - { - ranges.Add(c16); - ranges.Add(c16); - } - } - - return GCHandle.Alloc(ranges.ToArray(), GCHandleType.Pinned); - } - - /// - /// Creates a new GameFontHandle, and increases internal font reference counter, and if it's first time use, then the font will be loaded on next font building process. - /// - /// Font to use. - /// Handle to game font that may or may not be ready yet. - public GameFontHandle NewFontRef(GameFontStyle style) - { - var interfaceManager = Service.Get(); - var needRebuild = false; - - lock (this.syncRoot) - { - this.fontUseCounter[style] = this.fontUseCounter.GetValueOrDefault(style, 0) + 1; - } - - needRebuild = !this.fonts.ContainsKey(style); - if (needRebuild) - { - Log.Information("[GameFontManager] NewFontRef: Queueing RebuildFonts because {0} has been requested.", style.ToString()); - Service.GetAsync() - .ContinueWith(task => task.Result.RunOnTick(() => interfaceManager.RebuildFonts())); - } - - return new(this, style); - } - - /// - /// Gets the font. - /// - /// Font to get. - /// Corresponding font or null. - public ImFontPtr? GetFont(GameFontStyle style) => this.fonts.GetValueOrDefault(style, null); - - /// - /// Gets the corresponding FdtReader. - /// - /// Font to get. - /// Corresponding FdtReader or null. - public FdtReader? GetFdtReader(GameFontFamilyAndSize family) => this.fdts[(int)family]; - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - public void CopyGlyphsAcrossFonts(ImFontPtr? source, GameFontStyle target, bool missingOnly, bool rebuildLookupTable) - { - ImGuiHelpers.CopyGlyphsAcrossFonts(source ?? default, this.fonts[target], missingOnly, rebuildLookupTable); - } - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - public void CopyGlyphsAcrossFonts(GameFontStyle source, ImFontPtr? target, bool missingOnly, bool rebuildLookupTable) - { - ImGuiHelpers.CopyGlyphsAcrossFonts(this.fonts[source], target ?? default, missingOnly, rebuildLookupTable); - } - - /// - /// Fills missing glyphs in target font from source font, if both are not null. - /// - /// Source font. - /// Target font. - /// Whether to copy missing glyphs only. - /// Whether to call target.BuildLookupTable(). - public void CopyGlyphsAcrossFonts(GameFontStyle source, GameFontStyle target, bool missingOnly, bool rebuildLookupTable) - { - ImGuiHelpers.CopyGlyphsAcrossFonts(this.fonts[source], this.fonts[target], missingOnly, rebuildLookupTable); - } - - /// - /// Build fonts before plugins do something more. To be called from InterfaceManager. - /// - public void BuildFonts() - { - this.isBetweenBuildFontsAndRightAfterImGuiIoFontsBuild = true; - - this.glyphRectIds.Clear(); - this.fonts.Clear(); - - lock (this.syncRoot) - { - foreach (var style in this.fontUseCounter.Keys) - this.EnsureFont(style); - } - } - - /// - /// Record that ImGui.GetIO().Fonts.Build() has been called. - /// - public void AfterIoFontsBuild() - { - this.isBetweenBuildFontsAndRightAfterImGuiIoFontsBuild = false; - } - - /// - /// Checks whether GameFontMamager owns an ImFont. - /// - /// ImFontPtr to check. - /// Whether it owns. - public bool OwnsFont(ImFontPtr fontPtr) => this.fonts.ContainsValue(fontPtr); - - /// - /// Post-build fonts before plugins do something more. To be called from InterfaceManager. - /// - public unsafe void AfterBuildFonts() - { - var interfaceManager = Service.Get(); - var ioFonts = ImGui.GetIO().Fonts; - var fontGamma = interfaceManager.FontGamma; - - var pixels8s = new byte*[ioFonts.Textures.Size]; - var pixels32s = new uint*[ioFonts.Textures.Size]; - var widths = new int[ioFonts.Textures.Size]; - var heights = new int[ioFonts.Textures.Size]; - for (var i = 0; i < pixels8s.Length; i++) - { - ioFonts.GetTexDataAsRGBA32(i, out pixels8s[i], out widths[i], out heights[i]); - pixels32s[i] = (uint*)pixels8s[i]; - } - - foreach (var (style, font) in this.fonts) - { - var fdt = this.fdts[(int)style.FamilyAndSize]; - var scale = style.SizePt / fdt.FontHeader.Size; - var fontPtr = font.NativePtr; - - Log.Verbose("[GameFontManager] AfterBuildFonts: Scaling {0} from {1}pt to {2}pt (scale: {3})", style.ToString(), fdt.FontHeader.Size, style.SizePt, scale); - - fontPtr->FontSize = fdt.FontHeader.Size * 4 / 3; - if (fontPtr->ConfigData != null) - fontPtr->ConfigData->SizePixels = fontPtr->FontSize; - fontPtr->Ascent = fdt.FontHeader.Ascent; - fontPtr->Descent = fdt.FontHeader.Descent; - fontPtr->EllipsisChar = '…'; - foreach (var fallbackCharCandidate in "〓?!") - { - var glyph = font.FindGlyphNoFallback(fallbackCharCandidate); - if ((IntPtr)glyph.NativePtr != IntPtr.Zero) - { - var ptr = font.NativePtr; - ptr->FallbackChar = fallbackCharCandidate; - ptr->FallbackGlyph = glyph.NativePtr; - ptr->FallbackHotData = (ImFontGlyphHotData*)ptr->IndexedHotData.Address(fallbackCharCandidate); - break; - } - } - - // I have no idea what's causing NPE, so just to be safe - try - { - if (font.NativePtr != null && font.NativePtr->ConfigData != null) - { - var nameBytes = Encoding.UTF8.GetBytes(style.ToString() + "\0"); - Marshal.Copy(nameBytes, 0, (IntPtr)font.ConfigData.Name.Data, Math.Min(nameBytes.Length, font.ConfigData.Name.Count)); - } - } - catch (NullReferenceException) - { - // do nothing - } - - foreach (var (c, (rectId, glyph)) in this.glyphRectIds[style]) - { - var rc = (ImFontAtlasCustomRectReal*)ioFonts.GetCustomRectByIndex(rectId).NativePtr; - var pixels8 = pixels8s[rc->TextureIndex]; - var pixels32 = pixels32s[rc->TextureIndex]; - var width = widths[rc->TextureIndex]; - var height = heights[rc->TextureIndex]; - var sourceBuffer = this.texturePixels[glyph.TextureFileIndex]; - var sourceBufferDelta = glyph.TextureChannelByteIndex; - var widthAdjustment = style.CalculateBaseWidthAdjustment(fdt, glyph); - if (widthAdjustment == 0) - { - for (var y = 0; y < glyph.BoundingHeight; y++) - { - for (var x = 0; x < glyph.BoundingWidth; x++) - { - var a = sourceBuffer[sourceBufferDelta + (4 * (((glyph.TextureOffsetY + y) * fdt.FontHeader.TextureWidth) + glyph.TextureOffsetX + x))]; - pixels32[((rc->Y + y) * width) + rc->X + x] = (uint)(a << 24) | 0xFFFFFFu; - } - } - } - else - { - for (var y = 0; y < glyph.BoundingHeight; y++) - { - for (var x = 0; x < glyph.BoundingWidth + widthAdjustment; x++) - pixels32[((rc->Y + y) * width) + rc->X + x] = 0xFFFFFFu; - } - - for (int xbold = 0, xbold_ = Math.Max(1, (int)Math.Ceiling(style.Weight + 1)); xbold < xbold_; xbold++) - { - var boldStrength = Math.Min(1f, style.Weight + 1 - xbold); - for (var y = 0; y < glyph.BoundingHeight; y++) - { - float xDelta = xbold; - if (style.BaseSkewStrength > 0) - xDelta += style.BaseSkewStrength * (fdt.FontHeader.LineHeight - glyph.CurrentOffsetY - y) / fdt.FontHeader.LineHeight; - else if (style.BaseSkewStrength < 0) - xDelta -= style.BaseSkewStrength * (glyph.CurrentOffsetY + y) / fdt.FontHeader.LineHeight; - var xDeltaInt = (int)Math.Floor(xDelta); - var xness = xDelta - xDeltaInt; - for (var x = 0; x < glyph.BoundingWidth; x++) - { - var sourcePixelIndex = ((glyph.TextureOffsetY + y) * fdt.FontHeader.TextureWidth) + glyph.TextureOffsetX + x; - var a1 = sourceBuffer[sourceBufferDelta + (4 * sourcePixelIndex)]; - var a2 = x == glyph.BoundingWidth - 1 ? 0 : sourceBuffer[sourceBufferDelta + (4 * (sourcePixelIndex + 1))]; - var n = (a1 * xness) + (a2 * (1 - xness)); - var targetOffset = ((rc->Y + y) * width) + rc->X + x + xDeltaInt; - pixels8[(targetOffset * 4) + 3] = Math.Max(pixels8[(targetOffset * 4) + 3], (byte)(boldStrength * n)); - } - } - } - } - - if (Math.Abs(fontGamma - 1.4f) >= 0.001) - { - // Gamma correction (stbtt/FreeType would output in linear space whereas most real world usages will apply 1.4 or 1.8 gamma; Windows/XIV prebaked uses 1.4) - for (int y = rc->Y, y_ = rc->Y + rc->Height; y < y_; y++) - { - for (int x = rc->X, x_ = rc->X + rc->Width; x < x_; x++) - { - var i = (((y * width) + x) * 4) + 3; - pixels8[i] = (byte)(Math.Pow(pixels8[i] / 255.0f, 1.4f / fontGamma) * 255.0f); - } - } - } - } - - UnscaleFont(font, 1 / scale, false); - } - } - - /// - /// Decrease font reference counter. - /// - /// Font to release. - internal void DecreaseFontRef(GameFontStyle style) - { - lock (this.syncRoot) - { - if (!this.fontUseCounter.ContainsKey(style)) - return; - - if ((this.fontUseCounter[style] -= 1) == 0) - this.fontUseCounter.Remove(style); - } - } - - private unsafe void EnsureFont(GameFontStyle style) - { - var rectIds = this.glyphRectIds[style] = new(); - - var fdt = this.fdts[(int)style.FamilyAndSize]; - if (fdt == null) - return; - - ImFontConfigPtr fontConfig = ImGuiNative.ImFontConfig_ImFontConfig(); - fontConfig.OversampleH = 1; - fontConfig.OversampleV = 1; - fontConfig.PixelSnapH = false; - - var io = ImGui.GetIO(); - var font = io.Fonts.AddFontDefault(fontConfig); - - fontConfig.Destroy(); - - this.fonts[style] = font; - foreach (var glyph in fdt.Glyphs) - { - var c = glyph.Char; - if (c < 32 || c >= 0xFFFF) - continue; - - var widthAdjustment = style.CalculateBaseWidthAdjustment(fdt, glyph); - rectIds[c] = Tuple.Create( - io.Fonts.AddCustomRectFontGlyph( - font, - c, - glyph.BoundingWidth + widthAdjustment, - glyph.BoundingHeight, - glyph.AdvanceWidth, - new Vector2(0, glyph.CurrentOffsetY)), - glyph); - } - - foreach (var kernPair in fdt.Distances) - font.AddKerningPair(kernPair.Left, kernPair.Right, kernPair.RightOffset); - } -} diff --git a/Dalamud/Interface/GameFonts/GameFontStyle.cs b/Dalamud/Interface/GameFonts/GameFontStyle.cs index 946473df4..fbaf9de07 100644 --- a/Dalamud/Interface/GameFonts/GameFontStyle.cs +++ b/Dalamud/Interface/GameFonts/GameFontStyle.cs @@ -64,7 +64,7 @@ public struct GameFontStyle /// public float SizePt { - get => this.SizePx * 3 / 4; + readonly get => this.SizePx * 3 / 4; set => this.SizePx = value * 4 / 3; } @@ -73,14 +73,14 @@ public struct GameFontStyle /// public float BaseSkewStrength { - get => this.SkewStrength * this.BaseSizePx / this.SizePx; + readonly get => this.SkewStrength * this.BaseSizePx / this.SizePx; set => this.SkewStrength = value * this.SizePx / this.BaseSizePx; } /// /// Gets the font family. /// - public GameFontFamily Family => this.FamilyAndSize switch + public readonly GameFontFamily Family => this.FamilyAndSize switch { GameFontFamilyAndSize.Undefined => GameFontFamily.Undefined, GameFontFamilyAndSize.Axis96 => GameFontFamily.Axis, @@ -112,7 +112,7 @@ public struct GameFontStyle /// /// Gets the corresponding GameFontFamilyAndSize but with minimum possible font sizes. /// - public GameFontFamilyAndSize FamilyWithMinimumSize => this.Family switch + public readonly GameFontFamilyAndSize FamilyWithMinimumSize => this.Family switch { GameFontFamily.Axis => GameFontFamilyAndSize.Axis96, GameFontFamily.Jupiter => GameFontFamilyAndSize.Jupiter16, @@ -126,7 +126,7 @@ public struct GameFontStyle /// /// Gets the base font size in point unit. /// - public float BaseSizePt => this.FamilyAndSize switch + public readonly float BaseSizePt => this.FamilyAndSize switch { GameFontFamilyAndSize.Undefined => 0, GameFontFamilyAndSize.Axis96 => 9.6f, @@ -158,14 +158,14 @@ public struct GameFontStyle /// /// Gets the base font size in pixel unit. /// - public float BaseSizePx => this.BaseSizePt * 4 / 3; + public readonly float BaseSizePx => this.BaseSizePt * 4 / 3; /// /// Gets or sets a value indicating whether this font is bold. /// public bool Bold { - get => this.Weight > 0f; + readonly get => this.Weight > 0f; set => this.Weight = value ? 1f : 0f; } @@ -174,8 +174,8 @@ public struct GameFontStyle /// public bool Italic { - get => this.SkewStrength != 0; - set => this.SkewStrength = value ? this.SizePx / 7 : 0; + readonly get => this.SkewStrength != 0; + set => this.SkewStrength = value ? this.SizePx / 6 : 0; } /// @@ -233,13 +233,26 @@ public struct GameFontStyle _ => GameFontFamilyAndSize.Undefined, }; + /// + /// Creates a new scaled instance of struct. + /// + /// The scale. + /// The scaled instance. + public readonly GameFontStyle Scale(float scale) => new() + { + FamilyAndSize = GetRecommendedFamilyAndSize(this.Family, this.SizePt * scale), + SizePx = this.SizePx * scale, + Weight = this.Weight, + SkewStrength = this.SkewStrength * scale, + }; + /// /// Calculates the adjustment to width resulting fron Weight and SkewStrength. /// /// Font header. /// Glyph. /// Width adjustment in pixel unit. - public int CalculateBaseWidthAdjustment(in FdtReader.FontTableHeader header, in FdtReader.FontTableEntry glyph) + public readonly int CalculateBaseWidthAdjustment(in FdtReader.FontTableHeader header, in FdtReader.FontTableEntry glyph) { var widthDelta = this.Weight; switch (this.BaseSkewStrength) @@ -263,11 +276,11 @@ public struct GameFontStyle /// Font information. /// Glyph. /// Width adjustment in pixel unit. - public int CalculateBaseWidthAdjustment(FdtReader reader, FdtReader.FontTableEntry glyph) => + public readonly int CalculateBaseWidthAdjustment(FdtReader reader, FdtReader.FontTableEntry glyph) => this.CalculateBaseWidthAdjustment(reader.FontHeader, glyph); /// - public override string ToString() + public override readonly string ToString() { return $"GameFontStyle({this.FamilyAndSize}, {this.SizePt}pt, skew={this.SkewStrength}, weight={this.Weight})"; } diff --git a/Dalamud/Interface/Internal/DalamudIme.cs b/Dalamud/Interface/Internal/DalamudIme.cs index e030b4e50..28a9075bd 100644 --- a/Dalamud/Interface/Internal/DalamudIme.cs +++ b/Dalamud/Interface/Internal/DalamudIme.cs @@ -11,6 +11,7 @@ using System.Text.Unicode; using Dalamud.Game.Text; using Dalamud.Hooking.WndProcHook; using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using ImGuiNET; @@ -196,9 +197,9 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType { if (HanRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) { - if (Service.Get() - .GetFdtReader(GameFontFamilyAndSize.Axis12) - ?.FindGlyph(chr) is null) + if (Service.Get() + ?.GetFdtReader(GameFontFamilyAndSize.Axis12) + .FindGlyph(chr) is null) { if (!this.EncounteredHan) { diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 95415659b..b8ca98584 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -21,6 +21,7 @@ using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.SelfTest; using Dalamud.Interface.Internal.Windows.Settings; using Dalamud.Interface.Internal.Windows.StyleEditor; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Style; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; @@ -93,7 +94,8 @@ internal class DalamudInterface : IDisposable, IServiceType private DalamudInterface( Dalamud dalamud, DalamudConfiguration configuration, - InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene, + FontAtlasFactory fontAtlasFactory, + InterfaceManager interfaceManager, PluginImageCache pluginImageCache, DalamudAssetManager dalamudAssetManager, Game.Framework framework, @@ -103,7 +105,7 @@ internal class DalamudInterface : IDisposable, IServiceType { this.dalamud = dalamud; this.configuration = configuration; - this.interfaceManager = interfaceManagerWithScene.Manager; + this.interfaceManager = interfaceManager; this.WindowSystem = new WindowSystem("DalamudCore"); @@ -122,10 +124,14 @@ internal class DalamudInterface : IDisposable, IServiceType clientState, configuration, dalamudAssetManager, + fontAtlasFactory, framework, gameGui, titleScreenMenu) { IsOpen = false }; - this.changelogWindow = new ChangelogWindow(this.titleScreenMenuWindow) { IsOpen = false }; + this.changelogWindow = new ChangelogWindow( + this.titleScreenMenuWindow, + fontAtlasFactory, + dalamudAssetManager) { IsOpen = false }; this.profilerWindow = new ProfilerWindow() { IsOpen = false }; this.branchSwitcherWindow = new BranchSwitcherWindow() { IsOpen = false }; this.hitchSettingsWindow = new HitchSettingsWindow() { IsOpen = false }; @@ -207,6 +213,7 @@ internal class DalamudInterface : IDisposable, IServiceType { this.interfaceManager.Draw -= this.OnDraw; + this.WindowSystem.Windows.OfType().AggregateToDisposable().Dispose(); this.WindowSystem.RemoveAllWindows(); this.changelogWindow.Dispose(); @@ -660,7 +667,7 @@ internal class DalamudInterface : IDisposable, IServiceType } var antiDebug = Service.Get(); - if (ImGui.MenuItem("Enable AntiDebug", null, antiDebug.IsEnabled)) + if (ImGui.MenuItem("Disable Debugging Protections", null, antiDebug.IsEnabled)) { var newEnabled = !antiDebug.IsEnabled; if (newEnabled) @@ -856,9 +863,19 @@ internal class DalamudInterface : IDisposable, IServiceType if (ImGui.BeginMenu("Game")) { - if (ImGui.MenuItem("Replace ExceptionHandler")) + if (ImGui.MenuItem("Use in-game default ExceptionHandler")) { - this.dalamud.ReplaceExceptionHandler(); + this.dalamud.UseDefaultExceptionHandler(); + } + + if (ImGui.MenuItem("Use in-game debug ExceptionHandler")) + { + this.dalamud.UseDebugExceptionHandler(); + } + + if (ImGui.MenuItem("Disable in-game ExceptionHandler")) + { + this.dalamud.UseNoExceptionHandler(); } ImGui.EndMenu(); diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 48157fa86..6cf4a8b90 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -1,13 +1,11 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Text; -using System.Text.Unicode; -using System.Threading; +using System.Threading.Tasks; using Dalamud.Configuration.Internal; using Dalamud.Game; @@ -16,13 +14,13 @@ using Dalamud.Game.ClientState.Keys; using Dalamud.Game.Internal.DXGI; using Dalamud.Hooking; using Dalamud.Hooking.WndProcHook; -using Dalamud.Interface.GameFonts; using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Style; using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; -using Dalamud.Storage.Assets; using Dalamud.Utility; using Dalamud.Utility.Timing; using ImGuiNET; @@ -64,45 +62,35 @@ internal class InterfaceManager : IDisposable, IServiceType /// public const float DefaultFontSizePx = (DefaultFontSizePt * 4.0f) / 3.0f; - private const ushort Fallback1Codepoint = 0x3013; // Geta mark; FFXIV uses this to indicate that a glyph is missing. - private const ushort Fallback2Codepoint = '-'; // FFXIV uses dash if Geta mark is unavailable. + private readonly ConcurrentBag deferredDisposeTextures = new(); + private readonly ConcurrentBag deferredDisposeImFontLockeds = new(); - private readonly HashSet glyphRequests = new(); - private readonly Dictionary loadedFontInfo = new(); - - private readonly List deferredDisposeTextures = new(); - - [ServiceManager.ServiceDependency] - private readonly Framework framework = Service.Get(); - [ServiceManager.ServiceDependency] private readonly WndProcHookManager wndProcHookManager = Service.Get(); [ServiceManager.ServiceDependency] private readonly DalamudIme dalamudIme = Service.Get(); - private readonly ManualResetEvent fontBuildSignal; - private readonly SwapChainVtableResolver address; + private readonly SwapChainVtableResolver address = new(); private readonly Hook setCursorHook; private RawDX11Scene? scene; private Hook? presentHook; private Hook? resizeBuffersHook; + private IFontAtlas? dalamudAtlas; + private ILockedImFont? defaultFontResourceLock; + // can't access imgui IO before first present call private bool lastWantCapture = false; - private bool isRebuildingFonts = false; private bool isOverrideGameCursor = true; + private IntPtr gameWindowHandle; [ServiceManager.ServiceConstructor] private InterfaceManager() { this.setCursorHook = Hook.FromImport( null, "user32.dll", "SetCursor", 0, this.SetCursorDetour); - - this.fontBuildSignal = new ManualResetEvent(false); - - this.address = new SwapChainVtableResolver(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] @@ -117,43 +105,64 @@ internal class InterfaceManager : IDisposable, IServiceType /// /// This event gets called each frame to facilitate ImGui drawing. /// - public event RawDX11Scene.BuildUIDelegate Draw; + public event RawDX11Scene.BuildUIDelegate? Draw; /// /// This event gets called when ResizeBuffers is called. /// - public event Action ResizeBuffers; - - /// - /// Gets or sets an action that is executed right before fonts are rebuilt. - /// - public event Action BuildFonts; + public event Action? ResizeBuffers; /// /// Gets or sets an action that is executed right after fonts are rebuilt. /// - public event Action AfterBuildFonts; + public event Action? AfterBuildFonts; /// - /// Gets the default ImGui font. + /// Gets the default ImGui font.
+ /// Accessing this static property outside of the main thread is dangerous and not supported. ///
- public static ImFontPtr DefaultFont { get; private set; } + public static ImFontPtr DefaultFont => + WhenFontsReady().DefaultFontHandle!.LockUntilPostFrame().OrElse(ImGui.GetIO().FontDefault); /// - /// Gets an included FontAwesome icon font. + /// Gets an included FontAwesome icon font.
+ /// Accessing this static property outside of the main thread is dangerous and not supported. ///
- public static ImFontPtr IconFont { get; private set; } + public static ImFontPtr IconFont => + WhenFontsReady().IconFontHandle!.LockUntilPostFrame().OrElse(ImGui.GetIO().FontDefault); /// - /// Gets an included monospaced font. + /// Gets an included monospaced font.
+ /// Accessing this static property outside of the main thread is dangerous and not supported. ///
- public static ImFontPtr MonoFont { get; private set; } + public static ImFontPtr MonoFont => + WhenFontsReady().MonoFontHandle!.LockUntilPostFrame().OrElse(ImGui.GetIO().FontDefault); + + /// + /// Gets the default font handle. + /// + public FontHandle? DefaultFontHandle { get; private set; } + + /// + /// Gets the icon font handle. + /// + public FontHandle? IconFontHandle { get; private set; } + + /// + /// Gets the mono font handle. + /// + public FontHandle? MonoFontHandle { get; private set; } /// /// Gets or sets the pointer to ImGui.IO(), when it was last used. /// public ImGuiIOPtr LastImGuiIoPtr { get; set; } + /// + /// Gets the DX11 scene. + /// + public RawDX11Scene? Scene => this.scene; + /// /// Gets the D3D11 device instance. /// @@ -178,11 +187,6 @@ internal class InterfaceManager : IDisposable, IServiceType } } - /// - /// Gets or sets a value indicating whether the fonts are built and ready to use. - /// - public bool FontsReady { get; set; } = false; - /// /// Gets a value indicating whether the Dalamud interface ready to use. /// @@ -193,50 +197,64 @@ internal class InterfaceManager : IDisposable, IServiceType /// public bool IsDispatchingEvents { get; set; } = true; - /// - /// Gets or sets a value indicating whether to override configuration for UseAxis. - /// - public bool? UseAxisOverride { get; set; } = null; - - /// - /// Gets a value indicating whether to use AXIS fonts. - /// - public bool UseAxis => this.UseAxisOverride ?? Service.Get().UseAxisFontsFromGame; - - /// - /// Gets or sets the overrided font gamma value, instead of using the value from configuration. - /// - public float? FontGammaOverride { get; set; } = null; - - /// - /// Gets the font gamma value to use. - /// - public float FontGamma => Math.Max(0.1f, this.FontGammaOverride.GetValueOrDefault(Service.Get().FontGammaLevel)); - - /// - /// Gets a value indicating whether we're building fonts but haven't generated atlas yet. - /// - public bool IsBuildingFontsBeforeAtlasBuild => this.isRebuildingFonts && !this.fontBuildSignal.WaitOne(0); - /// /// Gets a value indicating the native handle of the game main window. /// - public IntPtr GameWindowHandle { get; private set; } + public IntPtr GameWindowHandle + { + get + { + if (this.gameWindowHandle == 0) + { + nint gwh = 0; + while ((gwh = NativeFunctions.FindWindowEx(0, gwh, "FFXIVGAME", 0)) != 0) + { + _ = User32.GetWindowThreadProcessId(gwh, out var pid); + if (pid == Environment.ProcessId && User32.IsWindowVisible(gwh)) + { + this.gameWindowHandle = gwh; + break; + } + } + } + + return this.gameWindowHandle; + } + } + + /// + /// Gets the font build task. + /// + public Task FontBuildTask => WhenFontsReady().dalamudAtlas!.BuildTask; + + /// + /// Gets the number of calls to so far. + /// + public long CumulativePresentCalls { get; private set; } /// /// Dispose of managed and unmanaged resources. /// public void Dispose() { - this.framework.RunOnFrameworkThread(() => + if (Service.GetNullable() is { } framework) + framework.RunOnFrameworkThread(Disposer).Wait(); + else + Disposer(); + + this.wndProcHookManager.PreWndProc -= this.WndProcHookManagerOnPreWndProc; + this.defaultFontResourceLock?.Dispose(); // lock outlives handle and atlas + this.defaultFontResourceLock = null; + this.dalamudAtlas?.Dispose(); + this.scene?.Dispose(); + return; + + void Disposer() { this.setCursorHook.Dispose(); this.presentHook?.Dispose(); this.resizeBuffersHook?.Dispose(); - }).Wait(); - - this.wndProcHookManager.PreWndProc -= this.WndProcHookManagerOnPreWndProc; - this.scene?.Dispose(); + } } #nullable enable @@ -376,93 +394,8 @@ internal class InterfaceManager : IDisposable, IServiceType /// public void RebuildFonts() { - if (this.scene == null) - { - Log.Verbose("[FONT] RebuildFonts(): scene not ready, doing nothing"); - return; - } - Log.Verbose("[FONT] RebuildFonts() called"); - - // don't invoke this multiple times per frame, in case multiple plugins call it - if (!this.isRebuildingFonts) - { - Log.Verbose("[FONT] RebuildFonts() trigger"); - this.isRebuildingFonts = true; - this.scene.OnNewRenderFrame += this.RebuildFontsInternal; - } - } - - /// - /// Wait for the rebuilding fonts to complete. - /// - public void WaitForFontRebuild() - { - this.fontBuildSignal.WaitOne(); - } - - /// - /// Requests a default font of specified size to exist. - /// - /// Font size in pixels. - /// Ranges of glyphs. - /// Requets handle. - public SpecialGlyphRequest NewFontSizeRef(float size, List> ranges) - { - var allContained = false; - var fonts = ImGui.GetIO().Fonts.Fonts; - ImFontPtr foundFont = null; - unsafe - { - for (int i = 0, i_ = fonts.Size; i < i_; i++) - { - if (!this.glyphRequests.Any(x => x.FontInternal.NativePtr == fonts[i].NativePtr)) - continue; - - allContained = true; - foreach (var range in ranges) - { - if (!allContained) - break; - - for (var j = range.Item1; j <= range.Item2 && allContained; j++) - allContained &= fonts[i].FindGlyphNoFallback(j).NativePtr != null; - } - - if (allContained) - foundFont = fonts[i]; - - break; - } - } - - var req = new SpecialGlyphRequest(this, size, ranges); - req.FontInternal = foundFont; - - if (!allContained) - this.RebuildFonts(); - - return req; - } - - /// - /// Requests a default font of specified size to exist. - /// - /// Font size in pixels. - /// Text to calculate glyph ranges from. - /// Requets handle. - public SpecialGlyphRequest NewFontSizeRef(float size, string text) - { - List> ranges = new(); - foreach (var c in new SortedSet(text.ToHashSet())) - { - if (ranges.Any() && ranges[^1].Item2 + 1 == c) - ranges[^1] = Tuple.Create(ranges[^1].Item1, c); - else - ranges.Add(Tuple.Create(c, c)); - } - - return this.NewFontSizeRef(size, ranges); + this.dalamudAtlas?.BuildFontsAsync(); } /// @@ -474,6 +407,15 @@ internal class InterfaceManager : IDisposable, IServiceType this.deferredDisposeTextures.Add(wrap); } + /// + /// Enqueue an to be disposed at the end of the frame. + /// + /// The disposable. + public void EnqueueDeferredDispose(in ILockedImFont locked) + { + this.deferredDisposeImFontLockeds.Add(locked); + } + /// /// Get video memory information. /// @@ -486,11 +428,11 @@ internal class InterfaceManager : IDisposable, IServiceType try { var dxgiDev = this.Device.QueryInterfaceOrNull(); - var dxgiAdapter = dxgiDev?.Adapter.QueryInterfaceOrNull(); + var dxgiAdapter = dxgiDev?.Adapter.QueryInterfaceOrNull(); if (dxgiAdapter == null) return null; - var memInfo = dxgiAdapter.QueryVideoMemoryInfo(0, SharpDX.DXGI.MemorySegmentGroup.Local); + var memInfo = dxgiAdapter.QueryVideoMemoryInfo(0, MemorySegmentGroup.Local); return (memInfo.CurrentUsage, memInfo.CurrentReservation); } catch @@ -516,20 +458,42 @@ internal class InterfaceManager : IDisposable, IServiceType /// Value. internal void SetImmersiveMode(bool enabled) { - if (this.GameWindowHandle == nint.Zero) - return; - - int value = enabled ? 1 : 0; - var hr = NativeFunctions.DwmSetWindowAttribute( - this.GameWindowHandle, - NativeFunctions.DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE, - ref value, - sizeof(int)); + if (this.GameWindowHandle == 0) + throw new InvalidOperationException("Game window is not yet ready."); + var value = enabled ? 1 : 0; + ((Result)NativeFunctions.DwmSetWindowAttribute( + this.GameWindowHandle, + NativeFunctions.DWMWINDOWATTRIBUTE.DWMWA_USE_IMMERSIVE_DARK_MODE, + ref value, + sizeof(int))).CheckError(); } - private static void ShowFontError(string path) + private static InterfaceManager WhenFontsReady() { - Util.Fatal($"One or more files required by XIVLauncher were not found.\nPlease restart and report this error if it occurs again.\n\n{path}", "Error"); + var im = Service.GetNullable(); + if (im?.dalamudAtlas is not { } atlas) + throw new InvalidOperationException($"Tried to access fonts before {nameof(ContinueConstruction)} call."); + + if (!atlas.HasBuiltAtlas) + atlas.BuildTask.GetAwaiter().GetResult(); + return im; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void RenderImGui(RawDX11Scene scene) + { + var conf = Service.Get(); + + // Process information needed by ImGuiHelpers each frame. + ImGuiHelpers.NewFrame(); + + // Enable viewports if there are no issues. + if (conf.IsDisableViewport || scene.SwapChain.IsFullScreen || ImGui.GetPlatformIO().Monitors.Size == 1) + ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.ViewportsEnable; + else + ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.ViewportsEnable; + + scene.Render(); } private void InitScene(IntPtr swapChain) @@ -546,7 +510,7 @@ internal class InterfaceManager : IDisposable, IServiceType Service.ProvideException(ex); Log.Error(ex, "Could not load ImGui dependencies."); - var res = PInvoke.User32.MessageBox( + var res = User32.MessageBox( IntPtr.Zero, "Dalamud plugins require the Microsoft Visual C++ Redistributable to be installed.\nPlease install the runtime from the official Microsoft website or disable Dalamud.\n\nDo you want to download the redistributable now?", "Dalamud Error", @@ -578,7 +542,7 @@ internal class InterfaceManager : IDisposable, IServiceType if (iniFileInfo.Length > 1200000) { Log.Warning("dalamudUI.ini was over 1mb, deleting"); - iniFileInfo.CopyTo(Path.Combine(iniFileInfo.DirectoryName, $"dalamudUI-{DateTimeOffset.Now.ToUnixTimeSeconds()}.ini")); + iniFileInfo.CopyTo(Path.Combine(iniFileInfo.DirectoryName!, $"dalamudUI-{DateTimeOffset.Now.ToUnixTimeSeconds()}.ini")); iniFileInfo.Delete(); } } @@ -623,8 +587,6 @@ internal class InterfaceManager : IDisposable, IServiceType ImGui.GetIO().FontGlobalScale = configuration.GlobalUiScale; - this.SetupFonts(); - if (!configuration.IsDocking) { ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.DockingEnable; @@ -675,507 +637,145 @@ internal class InterfaceManager : IDisposable, IServiceType */ private IntPtr PresentDetour(IntPtr swapChain, uint syncInterval, uint presentFlags) { + this.CumulativePresentCalls++; + + Debug.Assert(this.presentHook is not null, "How did PresentDetour get called when presentHook is null?"); + Debug.Assert(this.dalamudAtlas is not null, "dalamudAtlas should have been set already"); + if (this.scene != null && swapChain != this.scene.SwapChain.NativePointer) return this.presentHook!.Original(swapChain, syncInterval, presentFlags); if (this.scene == null) this.InitScene(swapChain); + Debug.Assert(this.scene is not null, "InitScene did not set the scene field, but did not throw an exception."); + + if (!this.dalamudAtlas!.HasBuiltAtlas) + return this.presentHook!.Original(swapChain, syncInterval, presentFlags); + if (this.address.IsReshade) { - var pRes = this.presentHook.Original(swapChain, syncInterval, presentFlags); + var pRes = this.presentHook!.Original(swapChain, syncInterval, presentFlags); - this.RenderImGui(); - this.DisposeTextures(); + RenderImGui(this.scene!); + this.CleanupPostImGuiRender(); return pRes; } - this.RenderImGui(); - this.DisposeTextures(); + RenderImGui(this.scene!); + this.CleanupPostImGuiRender(); - return this.presentHook.Original(swapChain, syncInterval, presentFlags); + return this.presentHook!.Original(swapChain, syncInterval, presentFlags); } - private void DisposeTextures() + private void CleanupPostImGuiRender() { - if (this.deferredDisposeTextures.Count > 0) + if (!this.deferredDisposeTextures.IsEmpty) { - Log.Verbose("[IM] Disposing {Count} textures", this.deferredDisposeTextures.Count); - foreach (var texture in this.deferredDisposeTextures) + var count = 0; + while (this.deferredDisposeTextures.TryTake(out var d)) { - texture.RealDispose(); + count++; + d.RealDispose(); } - this.deferredDisposeTextures.Clear(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RenderImGui() - { - // Process information needed by ImGuiHelpers each frame. - ImGuiHelpers.NewFrame(); - - // Check if we can still enable viewports without any issues. - this.CheckViewportState(); - - this.scene.Render(); - } - - private void CheckViewportState() - { - var configuration = Service.Get(); - - if (configuration.IsDisableViewport || this.scene.SwapChain.IsFullScreen || ImGui.GetPlatformIO().Monitors.Size == 1) - { - ImGui.GetIO().ConfigFlags &= ~ImGuiConfigFlags.ViewportsEnable; - return; + Log.Verbose("[IM] Disposing {Count} textures", count); } - ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.ViewportsEnable; - } - - /// - /// Loads font for use in ImGui text functions. - /// - private unsafe void SetupFonts() - { - using var setupFontsTimings = Timings.Start("IM SetupFonts"); - - var gameFontManager = Service.Get(); - var dalamud = Service.Get(); - var io = ImGui.GetIO(); - var ioFonts = io.Fonts; - - var fontGamma = this.FontGamma; - - this.fontBuildSignal.Reset(); - ioFonts.Clear(); - ioFonts.TexDesiredWidth = 4096; - - Log.Verbose("[FONT] SetupFonts - 1"); - - foreach (var v in this.loadedFontInfo) - v.Value.Dispose(); - - this.loadedFontInfo.Clear(); - - Log.Verbose("[FONT] SetupFonts - 2"); - - ImFontConfigPtr fontConfig = null; - List garbageList = new(); - - try + if (!this.deferredDisposeImFontLockeds.IsEmpty) { - var dummyRangeHandle = GCHandle.Alloc(new ushort[] { '0', '0', 0 }, GCHandleType.Pinned); - garbageList.Add(dummyRangeHandle); - - fontConfig = ImGuiNative.ImFontConfig_ImFontConfig(); - fontConfig.OversampleH = 1; - fontConfig.OversampleV = 1; - - var fontPathJp = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKjp-Regular.otf"); - if (!File.Exists(fontPathJp)) - fontPathJp = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKjp-Medium.otf"); - if (!File.Exists(fontPathJp)) - ShowFontError(fontPathJp); - Log.Verbose("[FONT] fontPathJp = {0}", fontPathJp); - - var fontPathKr = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansCJKkr-Regular.otf"); - if (!File.Exists(fontPathKr)) - fontPathKr = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "NotoSansKR-Regular.otf"); - if (!File.Exists(fontPathKr)) - fontPathKr = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts", "malgun.ttf"); - if (!File.Exists(fontPathKr)) - fontPathKr = null; - Log.Verbose("[FONT] fontPathKr = {0}", fontPathKr); - - var fontPathChs = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts", "msyh.ttc"); - if (!File.Exists(fontPathChs)) - fontPathChs = null; - Log.Verbose("[FONT] fontPathChs = {0}", fontPathChs); - - var fontPathCht = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts", "msjh.ttc"); - if (!File.Exists(fontPathCht)) - fontPathCht = null; - Log.Verbose("[FONT] fontPathChs = {0}", fontPathCht); - - // Default font - Log.Verbose("[FONT] SetupFonts - Default font"); - var fontInfo = new TargetFontModification( - "Default", - this.UseAxis ? TargetFontModification.AxisMode.Overwrite : TargetFontModification.AxisMode.GameGlyphsOnly, - this.UseAxis ? DefaultFontSizePx : DefaultFontSizePx + 1, - io.FontGlobalScale); - Log.Verbose("[FONT] SetupFonts - Default corresponding AXIS size: {0}pt ({1}px)", fontInfo.SourceAxis.Style.BaseSizePt, fontInfo.SourceAxis.Style.BaseSizePx); - fontConfig.SizePixels = fontInfo.TargetSizePx * io.FontGlobalScale; - if (this.UseAxis) - { - fontConfig.GlyphRanges = dummyRangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = false; - DefaultFont = ioFonts.AddFontDefault(fontConfig); - this.loadedFontInfo[DefaultFont] = fontInfo; - } - else - { - var rangeHandle = gameFontManager.ToGlyphRanges(GameFontFamilyAndSize.Axis12); - garbageList.Add(rangeHandle); - - fontConfig.GlyphRanges = rangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = true; - DefaultFont = ioFonts.AddFontFromFileTTF(fontPathJp, fontConfig.SizePixels, fontConfig); - this.loadedFontInfo[DefaultFont] = fontInfo; - } - - if (fontPathKr != null - && (Service.Get().EffectiveLanguage == "ko" || this.dalamudIme.EncounteredHangul)) - { - fontConfig.MergeMode = true; - fontConfig.GlyphRanges = ioFonts.GetGlyphRangesKorean(); - fontConfig.PixelSnapH = true; - ioFonts.AddFontFromFileTTF(fontPathKr, fontConfig.SizePixels, fontConfig); - fontConfig.MergeMode = false; - } - - if (fontPathCht != null && Service.Get().EffectiveLanguage == "tw") - { - fontConfig.MergeMode = true; - var rangeHandle = GCHandle.Alloc(new ushort[] - { - (ushort)UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint, - (ushort)(UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint + - (UnicodeRanges.CjkUnifiedIdeographs.Length - 1)), - (ushort)UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint, - (ushort)(UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint + - (UnicodeRanges.CjkUnifiedIdeographsExtensionA.Length - 1)), - 0, - }, GCHandleType.Pinned); - garbageList.Add(rangeHandle); - fontConfig.GlyphRanges = rangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = true; - ioFonts.AddFontFromFileTTF(fontPathCht, fontConfig.SizePixels, fontConfig); - fontConfig.MergeMode = false; - } - else if (fontPathChs != null && (Service.Get().EffectiveLanguage == "zh" - || this.dalamudIme.EncounteredHan)) - { - fontConfig.MergeMode = true; - var rangeHandle = GCHandle.Alloc(new ushort[] - { - (ushort)UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint, - (ushort)(UnicodeRanges.CjkUnifiedIdeographs.FirstCodePoint + - (UnicodeRanges.CjkUnifiedIdeographs.Length - 1)), - (ushort)UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint, - (ushort)(UnicodeRanges.CjkUnifiedIdeographsExtensionA.FirstCodePoint + - (UnicodeRanges.CjkUnifiedIdeographsExtensionA.Length - 1)), - 0, - }, GCHandleType.Pinned); - garbageList.Add(rangeHandle); - fontConfig.GlyphRanges = rangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = true; - ioFonts.AddFontFromFileTTF(fontPathChs, fontConfig.SizePixels, fontConfig); - fontConfig.MergeMode = false; - } - - // FontAwesome icon font - Log.Verbose("[FONT] SetupFonts - FontAwesome icon font"); - { - var fontPathIcon = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "FontAwesomeFreeSolid.otf"); - if (!File.Exists(fontPathIcon)) - ShowFontError(fontPathIcon); - - var iconRangeHandle = GCHandle.Alloc(new ushort[] { 0xE000, 0xF8FF, 0, }, GCHandleType.Pinned); - garbageList.Add(iconRangeHandle); - - fontConfig.GlyphRanges = iconRangeHandle.AddrOfPinnedObject(); - fontConfig.PixelSnapH = true; - IconFont = ioFonts.AddFontFromFileTTF(fontPathIcon, DefaultFontSizePx * io.FontGlobalScale, fontConfig); - this.loadedFontInfo[IconFont] = new("Icon", TargetFontModification.AxisMode.GameGlyphsOnly, DefaultFontSizePx, io.FontGlobalScale); - } - - // Monospace font - Log.Verbose("[FONT] SetupFonts - Monospace font"); - { - var fontPathMono = Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "Inconsolata-Regular.ttf"); - if (!File.Exists(fontPathMono)) - ShowFontError(fontPathMono); - - fontConfig.GlyphRanges = IntPtr.Zero; - fontConfig.PixelSnapH = true; - MonoFont = ioFonts.AddFontFromFileTTF(fontPathMono, DefaultFontSizePx * io.FontGlobalScale, fontConfig); - this.loadedFontInfo[MonoFont] = new("Mono", TargetFontModification.AxisMode.GameGlyphsOnly, DefaultFontSizePx, io.FontGlobalScale); - } - - // Default font but in requested size for requested glyphs - Log.Verbose("[FONT] SetupFonts - Default font but in requested size for requested glyphs"); - { - Dictionary> extraFontRequests = new(); - foreach (var extraFontRequest in this.glyphRequests) - { - if (!extraFontRequests.ContainsKey(extraFontRequest.Size)) - extraFontRequests[extraFontRequest.Size] = new(); - extraFontRequests[extraFontRequest.Size].Add(extraFontRequest); - } - - foreach (var (fontSize, requests) in extraFontRequests) - { - List<(ushort, ushort)> codepointRanges = new(4 + requests.Sum(x => x.CodepointRanges.Count)) - { - new(Fallback1Codepoint, Fallback1Codepoint), - new(Fallback2Codepoint, Fallback2Codepoint), - // ImGui default ellipsis characters - new(0x2026, 0x2026), - new(0x0085, 0x0085), - }; - - foreach (var request in requests) - codepointRanges.AddRange(request.CodepointRanges.Select(x => (From: x.Item1, To: x.Item2))); - - codepointRanges.Sort(); - List flattenedRanges = new(); - foreach (var range in codepointRanges) - { - if (flattenedRanges.Any() && flattenedRanges[^1] >= range.Item1 - 1) - { - flattenedRanges[^1] = Math.Max(flattenedRanges[^1], range.Item2); - } - else - { - flattenedRanges.Add(range.Item1); - flattenedRanges.Add(range.Item2); - } - } - - flattenedRanges.Add(0); - - fontInfo = new( - $"Requested({fontSize}px)", - this.UseAxis ? TargetFontModification.AxisMode.Overwrite : TargetFontModification.AxisMode.GameGlyphsOnly, - fontSize, - io.FontGlobalScale); - if (this.UseAxis) - { - fontConfig.GlyphRanges = dummyRangeHandle.AddrOfPinnedObject(); - fontConfig.SizePixels = fontInfo.SourceAxis.Style.BaseSizePx; - fontConfig.PixelSnapH = false; - - var sizedFont = ioFonts.AddFontDefault(fontConfig); - this.loadedFontInfo[sizedFont] = fontInfo; - foreach (var request in requests) - request.FontInternal = sizedFont; - } - else - { - var rangeHandle = GCHandle.Alloc(flattenedRanges.ToArray(), GCHandleType.Pinned); - garbageList.Add(rangeHandle); - fontConfig.PixelSnapH = true; - - var sizedFont = ioFonts.AddFontFromFileTTF(fontPathJp, fontSize * io.FontGlobalScale, fontConfig, rangeHandle.AddrOfPinnedObject()); - this.loadedFontInfo[sizedFont] = fontInfo; - foreach (var request in requests) - request.FontInternal = sizedFont; - } - } - } - - gameFontManager.BuildFonts(); - - var customFontFirstConfigIndex = ioFonts.ConfigData.Size; - - Log.Verbose("[FONT] Invoke OnBuildFonts"); - this.BuildFonts?.InvokeSafely(); - Log.Verbose("[FONT] OnBuildFonts OK!"); - - for (int i = customFontFirstConfigIndex, i_ = ioFonts.ConfigData.Size; i < i_; i++) - { - var config = ioFonts.ConfigData[i]; - if (gameFontManager.OwnsFont(config.DstFont)) - continue; - - config.OversampleH = 1; - config.OversampleV = 1; - - var name = Encoding.UTF8.GetString((byte*)config.Name.Data, config.Name.Count).TrimEnd('\0'); - if (name.IsNullOrEmpty()) - name = $"{config.SizePixels}px"; - - // ImFont information is reflected only if corresponding ImFontConfig has MergeMode not set. - if (config.MergeMode) - { - if (!this.loadedFontInfo.ContainsKey(config.DstFont.NativePtr)) - { - Log.Warning("MergeMode specified for {0} but not found in loadedFontInfo. Skipping.", name); - continue; - } - } - else - { - if (this.loadedFontInfo.ContainsKey(config.DstFont.NativePtr)) - { - Log.Warning("MergeMode not specified for {0} but found in loadedFontInfo. Skipping.", name); - continue; - } - - // While the font will be loaded in the scaled size after FontScale is applied, the font will be treated as having the requested size when used from plugins. - this.loadedFontInfo[config.DstFont.NativePtr] = new($"PlReq({name})", config.SizePixels); - } - - config.SizePixels = config.SizePixels * io.FontGlobalScale; - } - - for (int i = 0, i_ = ioFonts.ConfigData.Size; i < i_; i++) - { - var config = ioFonts.ConfigData[i]; - config.RasterizerGamma *= fontGamma; - } - - Log.Verbose("[FONT] ImGui.IO.Build will be called."); - ioFonts.Build(); - gameFontManager.AfterIoFontsBuild(); - this.ClearStacks(); - Log.Verbose("[FONT] ImGui.IO.Build OK!"); - - gameFontManager.AfterBuildFonts(); - - foreach (var (font, mod) in this.loadedFontInfo) - { - // I have no idea what's causing NPE, so just to be safe - try - { - if (font.NativePtr != null && font.NativePtr->ConfigData != null) - { - var nameBytes = Encoding.UTF8.GetBytes($"{mod.Name}\0"); - Marshal.Copy(nameBytes, 0, (IntPtr)font.ConfigData.Name.Data, Math.Min(nameBytes.Length, font.ConfigData.Name.Count)); - } - } - catch (NullReferenceException) - { - // do nothing - } - - Log.Verbose("[FONT] {0}: Unscale with scale value of {1}", mod.Name, mod.Scale); - GameFontManager.UnscaleFont(font, mod.Scale, false); - - if (mod.Axis == TargetFontModification.AxisMode.Overwrite) - { - Log.Verbose("[FONT] {0}: Overwrite from AXIS of size {1}px (was {2}px)", mod.Name, mod.SourceAxis.ImFont.FontSize, font.FontSize); - GameFontManager.UnscaleFont(font, font.FontSize / mod.SourceAxis.ImFont.FontSize, false); - var ascentDiff = mod.SourceAxis.ImFont.Ascent - font.Ascent; - font.Ascent += ascentDiff; - font.Descent = ascentDiff; - font.FallbackChar = mod.SourceAxis.ImFont.FallbackChar; - font.EllipsisChar = mod.SourceAxis.ImFont.EllipsisChar; - ImGuiHelpers.CopyGlyphsAcrossFonts(mod.SourceAxis.ImFont, font, false, false); - } - else if (mod.Axis == TargetFontModification.AxisMode.GameGlyphsOnly) - { - Log.Verbose("[FONT] {0}: Overwrite game specific glyphs from AXIS of size {1}px", mod.Name, mod.SourceAxis.ImFont.FontSize, font.FontSize); - if (!this.UseAxis && font.NativePtr == DefaultFont.NativePtr) - mod.SourceAxis.ImFont.FontSize -= 1; - ImGuiHelpers.CopyGlyphsAcrossFonts(mod.SourceAxis.ImFont, font, true, false, 0xE020, 0xE0DB); - if (!this.UseAxis && font.NativePtr == DefaultFont.NativePtr) - mod.SourceAxis.ImFont.FontSize += 1; - } - - Log.Verbose("[FONT] {0}: Resize from {1}px to {2}px", mod.Name, font.FontSize, mod.TargetSizePx); - GameFontManager.UnscaleFont(font, font.FontSize / mod.TargetSizePx, false); - } - - // Fill missing glyphs in MonoFont from DefaultFont - ImGuiHelpers.CopyGlyphsAcrossFonts(DefaultFont, MonoFont, true, false); - - for (int i = 0, i_ = ioFonts.Fonts.Size; i < i_; i++) - { - var font = ioFonts.Fonts[i]; - if (font.Glyphs.Size == 0) - { - Log.Warning("[FONT] Font has no glyph: {0}", font.GetDebugName()); - continue; - } - - if (font.FindGlyphNoFallback(Fallback1Codepoint).NativePtr != null) - font.FallbackChar = Fallback1Codepoint; - - font.BuildLookupTableNonstandard(); - } - - Log.Verbose("[FONT] Invoke OnAfterBuildFonts"); - this.AfterBuildFonts?.InvokeSafely(); - Log.Verbose("[FONT] OnAfterBuildFonts OK!"); - - if (ioFonts.Fonts[0].NativePtr != DefaultFont.NativePtr) - Log.Warning("[FONT] First font is not DefaultFont"); - - Log.Verbose("[FONT] Fonts built!"); - - this.fontBuildSignal.Set(); - - this.FontsReady = true; - } - finally - { - if (fontConfig.NativePtr != null) - fontConfig.Destroy(); - - foreach (var garbage in garbageList) - garbage.Free(); + // Not logging; the main purpose of this is to keep resources used for rendering the frame to be kept + // referenced until the resources are actually done being used, and it is expected that this will be + // frequent. + while (this.deferredDisposeImFontLockeds.TryTake(out var d)) + d.Dispose(); } } [ServiceManager.CallWhenServicesReady( "InterfaceManager accepts event registration and stuff even when the game window is not ready.")] - private void ContinueConstruction(TargetSigScanner sigScanner, DalamudConfiguration configuration) + private void ContinueConstruction( + TargetSigScanner sigScanner, + Framework framework, + FontAtlasFactory fontAtlasFactory) { - this.address.Setup(sigScanner); - this.framework.RunOnFrameworkThread(() => + this.dalamudAtlas = fontAtlasFactory + .CreateFontAtlas(nameof(InterfaceManager), FontAtlasAutoRebuildMode.Disable); + using (this.dalamudAtlas.SuppressAutoRebuild()) { - while ((this.GameWindowHandle = NativeFunctions.FindWindowEx(IntPtr.Zero, this.GameWindowHandle, "FFXIVGAME", IntPtr.Zero)) != IntPtr.Zero) + this.DefaultFontHandle = (FontHandle)this.dalamudAtlas.NewDelegateFontHandle( + e => e.OnPreBuild(tk => tk.AddDalamudDefaultFont(DefaultFontSizePx))); + this.IconFontHandle = (FontHandle)this.dalamudAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + tk => tk.AddFontAwesomeIconFont( + new() + { + SizePx = DefaultFontSizePx, + GlyphMinAdvanceX = DefaultFontSizePx, + GlyphMaxAdvanceX = DefaultFontSizePx, + }))); + this.MonoFontHandle = (FontHandle)this.dalamudAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + tk => tk.AddDalamudAssetFont( + DalamudAsset.InconsolataRegular, + new() { SizePx = DefaultFontSizePx }))); + this.dalamudAtlas.BuildStepChange += e => e.OnPostBuild( + tk => + { + // Fill missing glyphs in MonoFont from DefaultFont. + tk.CopyGlyphsAcrossFonts( + tk.GetFont(this.DefaultFontHandle), + tk.GetFont(this.MonoFontHandle), + missingOnly: true); + }); + this.DefaultFontHandle.ImFontChanged += (_, font) => { - _ = User32.GetWindowThreadProcessId(this.GameWindowHandle, out var pid); + var fontLocked = font.NewRef(); + Service.Get().RunOnFrameworkThread( + () => + { + // Update the ImGui default font. + unsafe + { + ImGui.GetIO().NativePtr->FontDefault = fontLocked.ImFont; + } - if (pid == Environment.ProcessId && User32.IsWindowVisible(this.GameWindowHandle)) - break; - } + // Update the reference to the resources of the default font. + this.defaultFontResourceLock?.Dispose(); + this.defaultFontResourceLock = fontLocked; - try - { - if (configuration.WindowIsImmersive) - this.SetImmersiveMode(true); - } - catch (Exception ex) - { - Log.Error(ex, "Could not enable immersive mode"); - } + // Broadcast to auto-rebuilding instances. + this.AfterBuildFonts?.Invoke(); + }); + }; + } - this.presentHook = Hook.FromAddress(this.address.Present, this.PresentDetour); - this.resizeBuffersHook = Hook.FromAddress(this.address.ResizeBuffers, this.ResizeBuffersDetour); + // This will wait for scene on its own. We just wait for this.dalamudAtlas.BuildTask in this.InitScene. + _ = this.dalamudAtlas.BuildFontsAsync(); - Log.Verbose("===== S W A P C H A I N ====="); - Log.Verbose($"Present address 0x{this.presentHook!.Address.ToInt64():X}"); - Log.Verbose($"ResizeBuffers address 0x{this.resizeBuffersHook!.Address.ToInt64():X}"); + this.address.Setup(sigScanner); - this.setCursorHook.Enable(); - this.presentHook.Enable(); - this.resizeBuffersHook.Enable(); - }); - } + try + { + if (Service.Get().WindowIsImmersive) + this.SetImmersiveMode(true); + } + catch (Exception ex) + { + Log.Error(ex, "Could not enable immersive mode"); + } - // This is intended to only be called as a handler attached to scene.OnNewRenderFrame - private void RebuildFontsInternal() - { - Log.Verbose("[FONT] RebuildFontsInternal() called"); - this.SetupFonts(); + this.presentHook = Hook.FromAddress(this.address.Present, this.PresentDetour); + this.resizeBuffersHook = Hook.FromAddress(this.address.ResizeBuffers, this.ResizeBuffersDetour); - Log.Verbose("[FONT] RebuildFontsInternal() detaching"); - this.scene!.OnNewRenderFrame -= this.RebuildFontsInternal; + Log.Verbose("===== S W A P C H A I N ====="); + Log.Verbose($"Present address 0x{this.presentHook!.Address.ToInt64():X}"); + Log.Verbose($"ResizeBuffers address 0x{this.resizeBuffersHook!.Address.ToInt64():X}"); - Log.Verbose("[FONT] Calling InvalidateFonts"); - this.scene.InvalidateFonts(); - - Log.Verbose("[FONT] Font Rebuild OK!"); - - this.isRebuildingFonts = false; + this.setCursorHook.Enable(); + this.presentHook.Enable(); + this.resizeBuffersHook.Enable(); } private IntPtr ResizeBuffersDetour(IntPtr swapChain, uint bufferCount, uint width, uint height, uint newFormat, uint swapChainFlags) @@ -1206,14 +806,17 @@ internal class InterfaceManager : IDisposable, IServiceType private IntPtr SetCursorDetour(IntPtr hCursor) { - if (this.lastWantCapture == true && (!this.scene?.IsImGuiCursor(hCursor) ?? false) && this.OverrideGameCursor) + if (this.lastWantCapture && (!this.scene?.IsImGuiCursor(hCursor) ?? false) && this.OverrideGameCursor) return IntPtr.Zero; - return this.setCursorHook.IsDisposed ? User32.SetCursor(new User32.SafeCursorHandle(hCursor, false)).DangerousGetHandle() : this.setCursorHook.Original(hCursor); + return this.setCursorHook.IsDisposed + ? User32.SetCursor(new(hCursor, false)).DangerousGetHandle() + : this.setCursorHook.Original(hCursor); } private void OnNewInputFrame() { + var io = ImGui.GetIO(); var dalamudInterface = Service.GetNullable(); var gamepadState = Service.GetNullable(); var keyState = Service.GetNullable(); @@ -1221,18 +824,21 @@ internal class InterfaceManager : IDisposable, IServiceType if (dalamudInterface == null || gamepadState == null || keyState == null) return; + // Prevent setting the footgun from ImGui Demo; the Space key isn't removing the flag at the moment. + io.ConfigFlags &= ~ImGuiConfigFlags.NoMouse; + // fix for keys in game getting stuck, if you were holding a game key (like run) // and then clicked on an imgui textbox - imgui would swallow the keyup event, // so the game would think the key remained pressed continuously until you left // imgui and pressed and released the key again - if (ImGui.GetIO().WantTextInput) + if (io.WantTextInput) { keyState.ClearAll(); } // TODO: mouse state? - var gamepadEnabled = (ImGui.GetIO().BackendFlags & ImGuiBackendFlags.HasGamepad) > 0; + var gamepadEnabled = (io.BackendFlags & ImGuiBackendFlags.HasGamepad) > 0; // NOTE (Chiv) Activate ImGui navigation via L1+L3 press // (mimicking how mouse navigation is activated via L1+R3 press in game). @@ -1240,12 +846,12 @@ internal class InterfaceManager : IDisposable, IServiceType && gamepadState.Raw(GamepadButtons.L1) > 0 && gamepadState.Pressed(GamepadButtons.L3) > 0) { - ImGui.GetIO().ConfigFlags ^= ImGuiConfigFlags.NavEnableGamepad; + io.ConfigFlags ^= ImGuiConfigFlags.NavEnableGamepad; gamepadState.NavEnableGamepad ^= true; dalamudInterface.ToggleGamepadModeNotifierWindow(); } - if (gamepadEnabled && (ImGui.GetIO().ConfigFlags & ImGuiConfigFlags.NavEnableGamepad) > 0) + if (gamepadEnabled && (io.ConfigFlags & ImGuiConfigFlags.NavEnableGamepad) > 0) { var northButton = gamepadState.Raw(GamepadButtons.North) != 0; var eastButton = gamepadState.Raw(GamepadButtons.East) != 0; @@ -1264,7 +870,6 @@ internal class InterfaceManager : IDisposable, IServiceType var r1Button = gamepadState.Raw(GamepadButtons.R1) != 0; var r2Button = gamepadState.Raw(GamepadButtons.R2) != 0; - var io = ImGui.GetIO(); io.AddKeyEvent(ImGuiKey.GamepadFaceUp, northButton); io.AddKeyEvent(ImGuiKey.GamepadFaceRight, eastButton); io.AddKeyEvent(ImGuiKey.GamepadFaceDown, southButton); @@ -1312,11 +917,12 @@ internal class InterfaceManager : IDisposable, IServiceType var snap = ImGuiManagedAsserts.GetSnapshot(); if (this.IsDispatchingEvents) + { this.Draw?.Invoke(); + Service.Get().Draw(); + } ImGuiManagedAsserts.ReportProblems("Dalamud Core", snap); - - Service.Get().Draw(); } /// @@ -1339,123 +945,4 @@ internal class InterfaceManager : IDisposable, IServiceType /// public InterfaceManager Manager { get; init; } } - - /// - /// Represents a glyph request. - /// - public class SpecialGlyphRequest : IDisposable - { - /// - /// Initializes a new instance of the class. - /// - /// InterfaceManager to associate. - /// Font size in pixels. - /// Codepoint ranges. - internal SpecialGlyphRequest(InterfaceManager manager, float size, List> ranges) - { - this.Manager = manager; - this.Size = size; - this.CodepointRanges = ranges; - this.Manager.glyphRequests.Add(this); - } - - /// - /// Gets the font of specified size, or DefaultFont if it's not ready yet. - /// - public ImFontPtr Font - { - get - { - unsafe - { - return this.FontInternal.NativePtr == null ? DefaultFont : this.FontInternal; - } - } - } - - /// - /// Gets or sets the associated ImFont. - /// - internal ImFontPtr FontInternal { get; set; } - - /// - /// Gets associated InterfaceManager. - /// - internal InterfaceManager Manager { get; init; } - - /// - /// Gets font size. - /// - internal float Size { get; init; } - - /// - /// Gets codepoint ranges. - /// - internal List> CodepointRanges { get; init; } - - /// - public void Dispose() - { - this.Manager.glyphRequests.Remove(this); - } - } - - private unsafe class TargetFontModification : IDisposable - { - /// - /// Initializes a new instance of the class. - /// Constructs new target font modification information, assuming that AXIS fonts will not be applied. - /// - /// Name of the font to write to ImGui font information. - /// Target font size in pixels, which will not be considered for further scaling. - internal TargetFontModification(string name, float sizePx) - { - this.Name = name; - this.Axis = AxisMode.Suppress; - this.TargetSizePx = sizePx; - this.Scale = 1; - this.SourceAxis = null; - } - - /// - /// Initializes a new instance of the class. - /// Constructs new target font modification information. - /// - /// Name of the font to write to ImGui font information. - /// Whether and how to use AXIS fonts. - /// Target font size in pixels, which will not be considered for further scaling. - /// Font scale to be referred for loading AXIS font of appropriate size. - internal TargetFontModification(string name, AxisMode axis, float sizePx, float globalFontScale) - { - this.Name = name; - this.Axis = axis; - this.TargetSizePx = sizePx; - this.Scale = globalFontScale; - this.SourceAxis = Service.Get().NewFontRef(new(GameFontFamily.Axis, this.TargetSizePx * this.Scale)); - } - - internal enum AxisMode - { - Suppress, - GameGlyphsOnly, - Overwrite, - } - - internal string Name { get; private init; } - - internal AxisMode Axis { get; private init; } - - internal float TargetSizePx { get; private init; } - - internal float Scale { get; private init; } - - internal GameFontHandle? SourceAxis { get; private init; } - - internal bool SourceAxisAvailable => this.SourceAxis != null && this.SourceAxis.ImFont.NativePtr != null; - - public void Dispose() - { - this.SourceAxis?.Dispose(); - } - } } diff --git a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs index b9e7ab686..ae59db36a 100644 --- a/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ChangelogWindow.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Linq; using System.Numerics; @@ -7,6 +6,8 @@ using Dalamud.Interface.Animation.EasingFunctions; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; @@ -31,8 +32,14 @@ internal sealed class ChangelogWindow : Window, IDisposable • Plugins can now add tooltips and interaction to the server info bar • The Dalamud/plugin installer UI has been refreshed "; - + private readonly TitleScreenMenuWindow tsmWindow; + + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly IFontAtlas privateAtlas; + private readonly Lazy bannerFont; + private readonly Lazy apiBumpExplainerTexture; + private readonly Lazy logoTexture; private readonly InOutCubic windowFade = new(TimeSpan.FromSeconds(2.5f)) { @@ -46,27 +53,36 @@ internal sealed class ChangelogWindow : Window, IDisposable Point2 = Vector2.One, }; - private IDalamudTextureWrap? apiBumpExplainerTexture; - private IDalamudTextureWrap? logoTexture; - private GameFontHandle? bannerFont; - private State state = State.WindowFadeIn; private bool needFadeRestart = false; - + /// /// Initializes a new instance of the class. /// /// TSM window. - public ChangelogWindow(TitleScreenMenuWindow tsmWindow) + /// An instance of . + /// An instance of . + public ChangelogWindow( + TitleScreenMenuWindow tsmWindow, + FontAtlasFactory fontAtlasFactory, + DalamudAssetManager assets) : base("What's new in Dalamud?##ChangelogWindow", ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse, true) { this.tsmWindow = tsmWindow; this.Namespace = "DalamudChangelogWindow"; + this.privateAtlas = this.scopedFinalizer.Add( + fontAtlasFactory.CreateFontAtlas(this.Namespace, FontAtlasAutoRebuildMode.Async)); + this.bannerFont = new( + () => this.scopedFinalizer.Add( + this.privateAtlas.NewGameFontHandle(new(GameFontFamilyAndSize.MiedingerMid18)))); + + this.apiBumpExplainerTexture = new(() => assets.GetDalamudTextureWrap(DalamudAsset.ChangelogApiBumpIcon)); + this.logoTexture = new(() => assets.GetDalamudTextureWrap(DalamudAsset.Logo)); // If we are going to show a changelog, make sure we have the font ready, otherwise it will hitch if (WarrantsChangelog()) - Service.GetAsync().ContinueWith(t => this.MakeFont(t.Result)); + _ = this.bannerFont.Value; } private enum State @@ -97,20 +113,12 @@ internal sealed class ChangelogWindow : Window, IDisposable Service.Get().SetCreditsDarkeningAnimation(true); this.tsmWindow.AllowDrawing = false; - this.MakeFont(Service.Get()); + _ = this.bannerFont; this.state = State.WindowFadeIn; this.windowFade.Reset(); this.bodyFade.Reset(); this.needFadeRestart = true; - - if (this.apiBumpExplainerTexture == null) - { - var dalamud = Service.Get(); - var tm = Service.Get(); - this.apiBumpExplainerTexture = tm.GetTextureFromFile(new FileInfo(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "changelogApiBump.png"))) - ?? throw new Exception("Could not load api bump explainer."); - } base.OnOpen(); } @@ -186,10 +194,7 @@ internal sealed class ChangelogWindow : Window, IDisposable ImGui.SetCursorPos(new Vector2(logoContainerSize.X / 2 - logoSize.X / 2, logoContainerSize.Y / 2 - logoSize.Y / 2)); using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.windowFade.EasedPoint.X - 0.5f, 0f, 1f))) - { - this.logoTexture ??= Service.Get().GetDalamudTextureWrap(DalamudAsset.Logo); - ImGui.Image(this.logoTexture.ImGuiHandle, logoSize); - } + ImGui.Image(this.logoTexture.Value.ImGuiHandle, logoSize); } ImGui.SameLine(); @@ -205,7 +210,7 @@ internal sealed class ChangelogWindow : Window, IDisposable using (ImRaii.PushStyle(ImGuiStyleVar.Alpha, Math.Clamp(this.windowFade.EasedPoint.X - 1f, 0f, 1f))) { - using var font = ImRaii.PushFont(this.bannerFont!.ImFont); + using var font = this.bannerFont.Value.Push(); switch (this.state) { @@ -275,9 +280,11 @@ internal sealed class ChangelogWindow : Window, IDisposable ImGui.TextWrapped("If some plugins are displayed with a red cross in the 'Installed Plugins' tab, they may not yet be available."); ImGuiHelpers.ScaledDummy(15); - - ImGuiHelpers.CenterCursorFor(this.apiBumpExplainerTexture!.Width); - ImGui.Image(this.apiBumpExplainerTexture.ImGuiHandle, this.apiBumpExplainerTexture.Size); + + ImGuiHelpers.CenterCursorFor(this.apiBumpExplainerTexture.Value.Width); + ImGui.Image( + this.apiBumpExplainerTexture.Value.ImGuiHandle, + this.apiBumpExplainerTexture.Value.Size); DrawNextButton(State.Links); break; @@ -377,7 +384,4 @@ internal sealed class ChangelogWindow : Window, IDisposable public void Dispose() { } - - private void MakeFont(GameFontManager gfm) => - this.bannerFont ??= gfm.NewFontRef(new GameFontStyle(GameFontFamilyAndSize.MiedingerMid18)); } diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index 20c3d6d01..951d3d91c 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -6,6 +6,8 @@ using Dalamud.Interface.Components; using Dalamud.Interface.Internal.Windows.Data.Widgets; using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; +using Dalamud.Utility; + using ImGuiNET; using Serilog; @@ -14,7 +16,7 @@ namespace Dalamud.Interface.Internal.Windows.Data; /// /// Class responsible for drawing the data/debug window. /// -internal class DataWindow : Window +internal class DataWindow : Window, IDisposable { private readonly IDataWindowWidget[] modules = { @@ -34,6 +36,7 @@ internal class DataWindow : Window new FlyTextWidget(), new FontAwesomeTestWidget(), new GameInventoryTestWidget(), + new GamePrebakedFontsTestWidget(), new GamepadWidget(), new GaugeWidget(), new HookWidget(), @@ -76,6 +79,9 @@ internal class DataWindow : Window this.Load(); } + /// + public void Dispose() => this.modules.OfType().AggregateToDisposable().Dispose(); + /// public override void OnOpen() { diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs index 8ec704888..c4c74274a 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs @@ -1,4 +1,8 @@ -using Dalamud.Game.Command; +using System.Linq; + +using Dalamud.Game.Command; +using Dalamud.Interface.Utility.Raii; + using ImGuiNET; namespace Dalamud.Interface.Internal.Windows.Data.Widgets; @@ -28,9 +32,52 @@ internal class CommandWidget : IDataWindowWidget { var commandManager = Service.Get(); - foreach (var command in commandManager.Commands) + var tableFlags = ImGuiTableFlags.ScrollY | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchProp | + ImGuiTableFlags.Sortable | ImGuiTableFlags.SortTristate; + using var table = ImRaii.Table("CommandList", 4, tableFlags); + if (table) { - ImGui.Text($"{command.Key}\n -> {command.Value.HelpMessage}\n -> In help: {command.Value.ShowInHelp}\n\n"); + ImGui.TableSetupScrollFreeze(0, 1); + + ImGui.TableSetupColumn("Command"); + ImGui.TableSetupColumn("Plugin"); + ImGui.TableSetupColumn("HelpMessage", ImGuiTableColumnFlags.NoSort); + ImGui.TableSetupColumn("In Help?", ImGuiTableColumnFlags.NoSort); + ImGui.TableHeadersRow(); + + var sortSpecs = ImGui.TableGetSortSpecs(); + var commands = commandManager.Commands.ToArray(); + + if (sortSpecs.SpecsCount != 0) + { + commands = sortSpecs.Specs.ColumnIndex switch + { + 0 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending + ? commands.OrderBy(kv => kv.Key).ToArray() + : commands.OrderByDescending(kv => kv.Key).ToArray(), + 1 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending + ? commands.OrderBy(kv => kv.Value.LoaderAssemblyName).ToArray() + : commands.OrderByDescending(kv => kv.Value.LoaderAssemblyName).ToArray(), + _ => commands, + }; + } + + foreach (var command in commands) + { + ImGui.TableNextRow(); + + ImGui.TableSetColumnIndex(0); + ImGui.Text(command.Key); + + ImGui.TableNextColumn(); + ImGui.Text(command.Value.LoaderAssemblyName); + + ImGui.TableNextColumn(); + ImGui.TextWrapped(command.Value.HelpMessage); + + ImGui.TableNextColumn(); + ImGui.Text(command.Value.ShowInHelp ? "Yes" : "No"); + } } } } diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs new file mode 100644 index 000000000..b486cc7d9 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs @@ -0,0 +1,278 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +using Serilog; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Widget for testing game prebaked fonts. +/// +internal class GamePrebakedFontsTestWidget : IDataWindowWidget, IDisposable +{ + private ImVectorWrapper testStringBuffer; + private IFontAtlas? privateAtlas; + private IReadOnlyDictionary Handle)[]>? fontHandles; + private bool useGlobalScale; + private bool useWordWrap; + private bool useItalic; + private bool useBold; + private bool useMinimumBuild; + + /// + public string[]? CommandShortcuts { get; init; } + + /// + public string DisplayName { get; init; } = "Game Prebaked Fonts"; + + /// + public bool Ready { get; set; } + + /// + public void Load() => this.Ready = true; + + /// + public unsafe void Draw() + { + ImGui.AlignTextToFramePadding(); + fixed (byte* labelPtr = "Global Scale"u8) + { + var v = (byte)(this.useGlobalScale ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useGlobalScale = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Word Wrap"u8) + { + var v = (byte)(this.useWordWrap ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + this.useWordWrap = v != 0; + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Italic"u8) + { + var v = (byte)(this.useItalic ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useItalic = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Bold"u8) + { + var v = (byte)(this.useBold ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useBold = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + fixed (byte* labelPtr = "Minimum Range"u8) + { + var v = (byte)(this.useMinimumBuild ? 1 : 0); + if (ImGuiNative.igCheckbox(labelPtr, &v) != 0) + { + this.useMinimumBuild = v != 0; + this.ClearAtlas(); + } + } + + ImGui.SameLine(); + if (ImGui.Button("Reset Text") || this.testStringBuffer.IsDisposed) + { + this.testStringBuffer.Dispose(); + this.testStringBuffer = ImVectorWrapper.CreateFromSpan( + "(Game)-[Font] {Test}. 0123456789!! <氣気气きキ기>。"u8, + minCapacity: 1024); + } + + ImGui.SameLine(); + if (ImGui.Button("Test Lock")) + Task.Run(this.TestLock); + + fixed (byte* labelPtr = "Test Input"u8) + { + if (ImGuiNative.igInputTextMultiline( + labelPtr, + this.testStringBuffer.Data, + (uint)this.testStringBuffer.Capacity, + new(ImGui.GetContentRegionAvail().X, 32 * ImGuiHelpers.GlobalScale), + 0, + null, + null) != 0) + { + var len = this.testStringBuffer.StorageSpan.IndexOf((byte)0); + if (len + 4 >= this.testStringBuffer.Capacity) + this.testStringBuffer.EnsureCapacityExponential(len + 4); + if (len < this.testStringBuffer.Capacity) + { + this.testStringBuffer.LengthUnsafe = len; + this.testStringBuffer.StorageSpan[len] = default; + } + + if (this.useMinimumBuild) + _ = this.privateAtlas?.BuildFontsAsync(); + } + } + + this.privateAtlas ??= + Service.Get().CreateFontAtlas( + nameof(GamePrebakedFontsTestWidget), + FontAtlasAutoRebuildMode.Async, + this.useGlobalScale); + this.fontHandles ??= + Enum.GetValues() + .Where(x => x.GetAttribute() is not null) + .Select(x => new GameFontStyle(x) { Italic = this.useItalic, Bold = this.useBold }) + .GroupBy(x => x.Family) + .ToImmutableDictionary( + x => x.Key, + x => x.Select( + y => (y, new Lazy( + () => this.useMinimumBuild + ? this.privateAtlas.NewDelegateFontHandle( + e => + e.OnPreBuild( + tk => tk.AddGameGlyphs( + y, + Encoding.UTF8.GetString( + this.testStringBuffer.DataSpan).ToGlyphRange(), + default))) + : this.privateAtlas.NewGameFontHandle(y)))) + .ToArray()); + + var offsetX = ImGui.CalcTextSize("99.9pt").X + (ImGui.GetStyle().FramePadding.X * 2); + var counter = 0; + foreach (var (family, items) in this.fontHandles) + { + if (!ImGui.CollapsingHeader($"{family} Family")) + continue; + + foreach (var (gfs, handle) in items) + { + ImGui.TextUnformatted($"{gfs.SizePt}pt"); + ImGui.SameLine(offsetX); + ImGuiNative.igPushTextWrapPos(this.useWordWrap ? 0f : -1f); + try + { + if (handle.Value.LoadException is { } exc) + { + ImGui.TextUnformatted(exc.ToString()); + } + else if (!handle.Value.Available) + { + fixed (byte* labelPtr = "Loading..."u8) + ImGuiNative.igTextUnformatted(labelPtr, labelPtr + 8 + ((Environment.TickCount / 200) % 3)); + } + else + { + if (!this.useGlobalScale) + ImGuiNative.igSetWindowFontScale(1 / ImGuiHelpers.GlobalScale); + if (counter++ % 2 == 0) + { + using var pushPop = handle.Value.Push(); + ImGuiNative.igTextUnformatted( + this.testStringBuffer.Data, + this.testStringBuffer.Data + this.testStringBuffer.Length); + } + else + { + handle.Value.Push(); + ImGuiNative.igTextUnformatted( + this.testStringBuffer.Data, + this.testStringBuffer.Data + this.testStringBuffer.Length); + handle.Value.Pop(); + } + } + } + finally + { + ImGuiNative.igPopTextWrapPos(); + ImGuiNative.igSetWindowFontScale(1); + } + } + } + } + + /// + public void Dispose() + { + this.ClearAtlas(); + this.testStringBuffer.Dispose(); + } + + private void ClearAtlas() + { + this.fontHandles?.Values.SelectMany(x => x.Where(y => y.Handle.IsValueCreated).Select(y => y.Handle.Value)) + .AggregateToDisposable().Dispose(); + this.fontHandles = null; + this.privateAtlas?.Dispose(); + this.privateAtlas = null; + } + + private async void TestLock() + { + if (this.fontHandles is not { } fontHandlesCopy) + return; + + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {nameof(this.TestLock)} waiting for build"); + + await using var garbage = new DisposeSafety.ScopedFinalizer(); + var fonts = new List(); + IFontHandle[] handles; + try + { + handles = fontHandlesCopy.Values.SelectMany(x => x).Select(x => x.Handle.Value).ToArray(); + foreach (var handle in handles) + { + await handle.WaitAsync(); + var locked = handle.Lock(); + garbage.Add(locked); + fonts.Add(locked.ImFont); + } + } + catch (ObjectDisposedException) + { + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {nameof(this.TestLock)} cancelled"); + return; + } + + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {nameof(this.TestLock)} waiting in lock"); + await Task.Delay(5000); + + foreach (var (font, handle) in fonts.Zip(handles)) + TestSingle(font, handle); + + return; + + unsafe void TestSingle(ImFontPtr fontPtr, IFontHandle handle) + { + var dim = default(Vector2); + var test = "Test string"u8; + fixed (byte* pTest = test) + ImGuiNative.ImFont_CalcTextSizeA(&dim, fontPtr, fontPtr.FontSize, float.MaxValue, 0, pTest, null, null); + Log.Information($"{nameof(GamePrebakedFontsTestWidget)}: {handle} => {dim}"); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs index 528507229..29adbb3e5 100644 --- a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs +++ b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs @@ -98,6 +98,12 @@ internal class PluginImageCache : IDisposable, IServiceType /// public IDalamudTextureWrap TroubleIcon => this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TroubleIcon, this.EmptyTexture); + + /// + /// Gets the devPlugin icon overlay. + /// + public IDalamudTextureWrap DevPluginIcon => + this.dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.DevPluginIcon, this.EmptyTexture); /// /// Gets the plugin update icon overlay. diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 4233c169b..83d819634 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -107,6 +107,7 @@ internal class PluginInstallerWindow : Window, IDisposable private int updatePluginCount = 0; private List? updatedPlugins; + [SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "Makes sense like this")] private List pluginListAvailable = new(); private List pluginListInstalled = new(); private List pluginListUpdatable = new(); @@ -1126,45 +1127,79 @@ internal class PluginInstallerWindow : Window, IDisposable this.DrawChangelog(logEntry); } } - + + private record PluginInstallerAvailablePluginProxy(RemotePluginManifest? RemoteManifest, LocalPlugin? LocalPlugin); + +#pragma warning disable SA1201 private void DrawAvailablePluginList() +#pragma warning restore SA1201 { - var pluginList = this.pluginListAvailable; + var availableManifests = this.pluginListAvailable; + var installedPlugins = this.pluginListInstalled.ToList(); // Copy intended - if (pluginList.Count == 0) + if (availableManifests.Count == 0) { ImGui.TextColored(ImGuiColors.DalamudGrey, Locs.TabBody_SearchNoCompatible); return; } - var filteredManifests = pluginList + var filteredAvailableManifests = availableManifests .Where(rm => !this.IsManifestFiltered(rm)) .ToList(); - if (filteredManifests.Count == 0) + if (filteredAvailableManifests.Count == 0) { ImGui.TextColored(ImGuiColors.DalamudGrey2, Locs.TabBody_SearchNoMatching); return; } - // get list to show and reset category dirty flag - var categoryManifestsList = this.categoryManager.GetCurrentCategoryContent(filteredManifests); + var proxies = new List(); + + // Go through all AVAILABLE manifests, associate them with a NON-DEV local plugin, if one is available, and remove it from the pile + foreach (var availableManifest in this.categoryManager.GetCurrentCategoryContent(filteredAvailableManifests).Cast()) + { + var plugin = this.pluginListInstalled.FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName && plugin.Manifest.RepoUrl == availableManifest.RepoUrl); + + // We "consumed" this plugin from the pile and remove it. + if (plugin != null && !plugin.IsDev) + { + installedPlugins.Remove(plugin); + proxies.Add(new PluginInstallerAvailablePluginProxy(null, plugin)); + + continue; + } + + proxies.Add(new PluginInstallerAvailablePluginProxy(availableManifest, null)); + } + + // Now, add all applicable local plugins that haven't been "used up", in most cases either dev or orphaned plugins. + foreach (var installedPlugin in installedPlugins) + { + if (this.IsManifestFiltered(installedPlugin.Manifest)) + continue; + + // TODO: We should also check categories here, for good measure + + proxies.Add(new PluginInstallerAvailablePluginProxy(null, installedPlugin)); + } var i = 0; - foreach (var manifest in categoryManifestsList) + foreach (var proxy in proxies) { - if (manifest is not RemotePluginManifest remoteManifest) - continue; - var (isInstalled, plugin) = this.IsManifestInstalled(remoteManifest); + IPluginManifest applicableManifest = proxy.LocalPlugin != null ? proxy.LocalPlugin.Manifest : proxy.RemoteManifest; - ImGui.PushID($"{manifest.InternalName}{manifest.AssemblyVersion}"); - if (isInstalled) + if (applicableManifest == null) + throw new Exception("Could not determine manifest for available plugin"); + + ImGui.PushID($"{applicableManifest.InternalName}{applicableManifest.AssemblyVersion}"); + + if (proxy.LocalPlugin != null) { - this.DrawInstalledPlugin(plugin, i++, true); + this.DrawInstalledPlugin(proxy.LocalPlugin, i++, true); } - else + else if (proxy.RemoteManifest != null) { - this.DrawAvailablePlugin(remoteManifest, i++); + this.DrawAvailablePlugin(proxy.RemoteManifest, i++); } ImGui.PopID(); @@ -1828,8 +1863,7 @@ internal class PluginInstallerWindow : Window, IDisposable // Name ImGui.TextUnformatted(label); - // Verified Checkmark, don't show for dev plugins - if (plugin is null or { IsDev: false }) + // Verified Checkmark or dev plugin wrench { ImGui.SameLine(); ImGui.Text(" "); @@ -1839,8 +1873,15 @@ internal class PluginInstallerWindow : Window, IDisposable var unverifiedOutlineColor = KnownColor.Black.Vector(); var verifiedIconColor = KnownColor.RoyalBlue.Vector() with { W = 0.75f }; var unverifiedIconColor = KnownColor.Orange.Vector(); - - if (!isThirdParty) + var devIconOutlineColor = KnownColor.White.Vector(); + var devIconColor = KnownColor.MediumOrchid.Vector(); + + if (plugin is LocalDevPlugin) + { + this.DrawFontawesomeIconOutlined(FontAwesomeIcon.Wrench, devIconOutlineColor, devIconColor); + this.VerifiedCheckmarkFadeTooltip(label, "This is a dev plugin. You added it."); + } + else if (!isThirdParty) { this.DrawFontawesomeIconOutlined(FontAwesomeIcon.CheckCircle, verifiedOutlineColor, verifiedIconColor); this.VerifiedCheckmarkFadeTooltip(label, Locs.VerifiedCheckmark_VerifiedTooltip); @@ -1873,16 +1914,32 @@ internal class PluginInstallerWindow : Window, IDisposable if (plugin is { IsOutdated: true, IsBanned: false } || installableOutdated) { ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); - ImGui.TextWrapped(Locs.PluginBody_Outdated); + + var bodyText = Locs.PluginBody_Outdated + " "; + if (updateAvailable) + bodyText += Locs.PluginBody_Outdated_CanNowUpdate; + else + bodyText += Locs.PluginBody_Outdated_WaitForUpdate; + + ImGui.TextWrapped(bodyText); ImGui.PopStyleColor(); } else if (plugin is { IsBanned: true }) { // Banned warning ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudRed); - ImGuiHelpers.SafeTextWrapped(plugin.BanReason.IsNullOrEmpty() - ? Locs.PluginBody_Banned - : Locs.PluginBody_BannedReason(plugin.BanReason)); + + var bodyText = plugin.BanReason.IsNullOrEmpty() + ? Locs.PluginBody_Banned + : Locs.PluginBody_BannedReason(plugin.BanReason); + bodyText += " "; + + if (updateAvailable) + bodyText += Locs.PluginBody_Outdated_CanNowUpdate; + else + bodyText += Locs.PluginBody_Outdated_WaitForUpdate; + + ImGuiHelpers.SafeTextWrapped(bodyText); ImGui.PopStyleColor(); } @@ -2238,6 +2295,11 @@ internal class PluginInstallerWindow : Window, IDisposable } var availablePluginUpdate = this.pluginListUpdatable.FirstOrDefault(up => up.InstalledPlugin == plugin); + + // Dev plugins can never update + if (plugin.IsDev) + availablePluginUpdate = null; + // Update available if (availablePluginUpdate != default) { @@ -2494,7 +2556,7 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGui.MenuItem(Locs.PluginContext_DeletePluginConfigReload)) { - this.ShowDeletePluginConfigWarningModal(plugin.Name).ContinueWith(t => + this.ShowDeletePluginConfigWarningModal(plugin.Manifest.Name).ContinueWith(t => { var shouldDelete = t.Result; @@ -2509,7 +2571,7 @@ internal class PluginInstallerWindow : Window, IDisposable { this.installStatus = OperationStatus.Idle; - this.DisplayErrorContinuation(task, Locs.ErrorModal_DeleteConfigFail(plugin.Name)); + this.DisplayErrorContinuation(task, Locs.ErrorModal_DeleteConfigFail(plugin.Manifest.InternalName)); }); } }); @@ -2526,12 +2588,12 @@ internal class PluginInstallerWindow : Window, IDisposable var profileManager = Service.Get(); var config = Service.Get(); - var applicableForProfiles = plugin.Manifest.SupportsProfiles && !plugin.IsDev; + var applicableForProfiles = plugin.Manifest.SupportsProfiles /*&& !plugin.IsDev*/; var profilesThatWantThisPlugin = profileManager.Profiles - .Where(x => x.WantsPlugin(plugin.InternalName) != null) + .Where(x => x.WantsPlugin(plugin.Manifest.WorkingPluginId) != null) .ToArray(); var isInSingleProfile = profilesThatWantThisPlugin.Length == 1; - var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.InternalName); + var isDefaultPlugin = profileManager.IsInDefaultProfile(plugin.Manifest.WorkingPluginId); // Disable everything if the updater is running or another plugin is operating var disabled = this.updateStatus == OperationStatus.InProgress || this.installStatus == OperationStatus.InProgress; @@ -2566,17 +2628,17 @@ internal class PluginInstallerWindow : Window, IDisposable foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile)) { - var inProfile = profile.WantsPlugin(plugin.Manifest.InternalName) != null; + var inProfile = profile.WantsPlugin(plugin.Manifest.WorkingPluginId) != null; if (ImGui.Checkbox($"###profilePick{profile.Guid}{plugin.Manifest.InternalName}", ref inProfile)) { if (inProfile) { - Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.InternalName, true)) + Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true)) .ContinueWith(this.DisplayErrorContinuation, Locs.Profiles_CouldNotAdd); } else { - Task.Run(() => profile.RemoveAsync(plugin.Manifest.InternalName)) + Task.Run(() => profile.RemoveAsync(plugin.Manifest.WorkingPluginId)) .ContinueWith(this.DisplayErrorContinuation, Locs.Profiles_CouldNotRemove); } } @@ -2596,11 +2658,11 @@ internal class PluginInstallerWindow : Window, IDisposable if (ImGuiComponents.IconButton(FontAwesomeIcon.Times)) { // TODO: Work this out - Task.Run(() => profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, plugin.IsLoaded, false)) + Task.Run(() => profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, plugin.IsLoaded, false)) .GetAwaiter().GetResult(); foreach (var profile in profileManager.Profiles.Where(x => !x.IsDefaultProfile && x.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName))) { - Task.Run(() => profile.RemoveAsync(plugin.Manifest.InternalName, false)) + Task.Run(() => profile.RemoveAsync(plugin.Manifest.WorkingPluginId, false)) .GetAwaiter().GetResult(); } @@ -2674,7 +2736,7 @@ internal class PluginInstallerWindow : Window, IDisposable { await plugin.UnloadAsync(); await applicableProfile.AddOrUpdateAsync( - plugin.Manifest.InternalName, false, false); + plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); notifications.AddNotification(Locs.Notifications_PluginDisabled(plugin.Manifest.Name), Locs.Notifications_PluginDisabledTitle, NotificationType.Success); }).ContinueWith(t => @@ -2691,7 +2753,7 @@ internal class PluginInstallerWindow : Window, IDisposable this.loadingIndicatorKind = LoadingIndicatorKind.EnablingSingle; this.enableDisableWorkingPluginId = plugin.Manifest.WorkingPluginId; - await applicableProfile.AddOrUpdateAsync(plugin.Manifest.InternalName, true, false); + await applicableProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); await plugin.LoadAsync(PluginLoadReason.Installer); notifications.AddNotification(Locs.Notifications_PluginEnabled(plugin.Manifest.Name), Locs.Notifications_PluginEnabledTitle, NotificationType.Success); @@ -2712,7 +2774,7 @@ 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 applicableProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); await this.UpdateSinglePlugin(availableUpdate); } else @@ -2893,7 +2955,7 @@ internal class PluginInstallerWindow : Window, IDisposable if (localPlugin is LocalDevPlugin plugin) { var isInDefaultProfile = - Service.Get().IsInDefaultProfile(localPlugin.Manifest.InternalName); + Service.Get().IsInDefaultProfile(localPlugin.Manifest.WorkingPluginId); // https://colorswall.com/palette/2868/ var greenColor = new Vector4(0x5C, 0xB8, 0x5C, 0xFF) / 0xFF; @@ -3237,7 +3299,7 @@ internal class PluginInstallerWindow : Window, IDisposable this.pluginListAvailable.Sort((p1, p2) => p1.Name.CompareTo(p2.Name)); var profman = Service.Get(); - this.pluginListInstalled.Sort((p1, p2) => profman.IsInDefaultProfile(p1.InternalName).CompareTo(profman.IsInDefaultProfile(p2.InternalName))); + this.pluginListInstalled.Sort((p1, p2) => profman.IsInDefaultProfile(p1.Manifest.WorkingPluginId).CompareTo(profman.IsInDefaultProfile(p2.Manifest.WorkingPluginId))); break; default: throw new InvalidEnumArgumentException("Unknown plugin sort type."); @@ -3484,7 +3546,11 @@ internal class PluginInstallerWindow : Window, IDisposable public static string PluginBody_Plugin3rdPartyRepo(string url) => Loc.Localize("InstallerPlugin3rdPartyRepo", "From custom plugin repository {0}").Format(url); - public static string PluginBody_Outdated => Loc.Localize("InstallerOutdatedPluginBody ", "This plugin is outdated and incompatible at the moment. Please wait for it to be updated by its author."); + public static string PluginBody_Outdated => Loc.Localize("InstallerOutdatedPluginBody ", "This plugin is outdated and incompatible."); + + public static string PluginBody_Outdated_WaitForUpdate => Loc.Localize("InstallerOutdatedWaitForUpdate", "Please wait for it to be updated by its author."); + + public static string PluginBody_Outdated_CanNowUpdate => Loc.Localize("InstallerOutdatedCanNowUpdate", "An update is available for installation."); public static string PluginBody_Orphaned => Loc.Localize("InstallerOrphanedPluginBody ", "This plugin's source repository is no longer available. You may need to reinstall it from its repository, or re-add the repository."); @@ -3494,7 +3560,7 @@ internal class PluginInstallerWindow : Window, IDisposable public static string PluginBody_LoadFailed => Loc.Localize("InstallerLoadFailedPluginBody ", "This plugin failed to load. Please contact the author for more information."); - public static string PluginBody_Banned => Loc.Localize("InstallerBannedPluginBody ", "This plugin was automatically disabled due to incompatibilities and is not available at the moment. Please wait for it to be updated by its author."); + public static string PluginBody_Banned => Loc.Localize("InstallerBannedPluginBody ", "This plugin was automatically disabled due to incompatibilities and is not available."); public static string PluginBody_Policy => Loc.Localize("InstallerPolicyPluginBody ", "Plugin loads for this type of plugin were manually disabled."); @@ -3707,7 +3773,7 @@ internal class PluginInstallerWindow : Window, IDisposable public static string DeletePluginConfigWarningModal_Title => Loc.Localize("InstallerDeletePluginConfigWarning", "Warning###InstallerDeletePluginConfigWarning"); - public static string DeletePluginConfigWarningModal_Body(string pluginName) => Loc.Localize("InstallerDeletePluginConfigWarningBody", "Are you sure you want to delete all data and configuration for v{0}?").Format(pluginName); + public static string DeletePluginConfigWarningModal_Body(string pluginName) => Loc.Localize("InstallerDeletePluginConfigWarningBody", "Are you sure you want to delete all data and configuration for {0}?").Format(pluginName); public static string DeletePluginConfirmWarningModal_Yes => Loc.Localize("InstallerDeletePluginConfigWarningYes", "Yes"); diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs index 3f8f25f3e..eafea9d16 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/ProfileManagerWidget.cs @@ -12,6 +12,7 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Profiles; +using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using ImGuiNET; using Serilog; @@ -252,7 +253,7 @@ internal class ProfileManagerWidget if (ImGuiComponents.IconButton($"###exportButton{profile.Guid}", FontAwesomeIcon.FileExport)) { - ImGui.SetClipboardText(profile.Model.Serialize()); + ImGui.SetClipboardText(profile.Model.SerializeForShare()); Service.Get().AddNotification(Locs.CopyToClipboardNotification, type: NotificationType.Success); } @@ -315,15 +316,15 @@ internal class ProfileManagerWidget if (ImGui.BeginListBox("###pluginPicker", new Vector2(width, width - 80))) { // TODO: Plugin searching should be abstracted... installer and this should use the same search - foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles && !x.IsDev && + foreach (var plugin in pm.InstalledPlugins.Where(x => x.Manifest.SupportsProfiles && (this.pickerSearch.IsNullOrWhitespace() || x.Manifest.Name.ToLowerInvariant().Contains(this.pickerSearch.ToLowerInvariant())))) { using var disabled2 = ImRaii.Disabled(profile.Plugins.Any(y => y.InternalName == plugin.Manifest.InternalName)); - if (ImGui.Selectable($"{plugin.Manifest.Name}###selector{plugin.Manifest.InternalName}")) + if (ImGui.Selectable($"{plugin.Manifest.Name}{(plugin is LocalDevPlugin ? "(dev plugin)" : string.Empty)}###selector{plugin.Manifest.InternalName}")) { - Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.InternalName, true, false)) + Task.Run(() => profile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false)) .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState); } } @@ -350,7 +351,7 @@ internal class ProfileManagerWidget if (ImGuiComponents.IconButton(FontAwesomeIcon.FileExport)) { - ImGui.SetClipboardText(profile.Model.Serialize()); + ImGui.SetClipboardText(profile.Model.SerializeForShare()); Service.Get().AddNotification(Locs.CopyToClipboardNotification, type: NotificationType.Success); } @@ -423,24 +424,34 @@ internal class ProfileManagerWidget if (pluginListChild) { var pluginLineHeight = 32 * ImGuiHelpers.GlobalScale; - string? wantRemovePluginInternalName = null; + Guid? wantRemovePluginGuid = null; using var syncScope = profile.GetSyncScope(); - foreach (var plugin in profile.Plugins.ToArray()) + foreach (var profileEntry in profile.Plugins.ToArray()) { didAny = true; - var pmPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.InternalName == plugin.InternalName); + var pmPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.WorkingPluginId == profileEntry.WorkingPluginId); var btnOffset = 2; if (pmPlugin != null) { + var cursorBeforeIcon = ImGui.GetCursorPos(); pic.TryGetIcon(pmPlugin, pmPlugin.Manifest, pmPlugin.IsThirdParty, out var icon); icon ??= pic.DefaultIcon; ImGui.Image(icon.ImGuiHandle, new Vector2(pluginLineHeight)); + + if (pmPlugin.IsDev) + { + ImGui.SetCursorPos(cursorBeforeIcon); + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.7f); + ImGui.Image(pic.DevPluginIcon.ImGuiHandle, new Vector2(pluginLineHeight)); + ImGui.PopStyleVar(); + } + ImGui.SameLine(); - var text = $"{pmPlugin.Name}"; + var text = $"{pmPlugin.Name}{(pmPlugin.IsDev ? " (dev plugin" : string.Empty)}"; var textHeight = ImGui.CalcTextSize(text); var before = ImGui.GetCursorPos(); @@ -454,32 +465,53 @@ internal class ProfileManagerWidget ImGui.Image(pic.DefaultIcon.ImGuiHandle, new Vector2(pluginLineHeight)); ImGui.SameLine(); - var text = Locs.NotInstalled(plugin.InternalName); + var text = Locs.NotInstalled(profileEntry.InternalName); var textHeight = ImGui.CalcTextSize(text); var before = ImGui.GetCursorPos(); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (textHeight.Y / 2)); ImGui.TextUnformatted(text); - - var available = + + var firstAvailableInstalled = pm.InstalledPlugins.FirstOrDefault(x => x.InternalName == profileEntry.InternalName); + var installable = pm.AvailablePlugins.FirstOrDefault( - x => x.InternalName == plugin.InternalName && !x.SourceRepo.IsThirdParty); - if (available != null) + x => x.InternalName == profileEntry.InternalName && !x.SourceRepo.IsThirdParty); + + if (firstAvailableInstalled != null) + { + ImGui.Text($"Match to plugin '{firstAvailableInstalled.Name}'?"); + ImGui.SameLine(); + if (ImGuiComponents.IconButtonWithText( + FontAwesomeIcon.Check, + "Yes, use this one")) + { + profileEntry.WorkingPluginId = firstAvailableInstalled.Manifest.WorkingPluginId; + Task.Run(async () => + { + await profman.ApplyAllWantStatesAsync(); + }) + .ContinueWith(t => + { + this.installer.DisplayErrorContinuation(t, Locs.ErrorCouldNotChangeState); + }); + } + } + else if (installable != null) { ImGui.SameLine(); ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * 2) - 2); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); btnOffset = 3; - if (ImGuiComponents.IconButton($"###installMissingPlugin{available.InternalName}", FontAwesomeIcon.Download)) + if (ImGuiComponents.IconButton($"###installMissingPlugin{installable.InternalName}", FontAwesomeIcon.Download)) { - this.installer.StartInstall(available, false); + this.installer.StartInstall(installable, false); } if (ImGui.IsItemHovered()) ImGui.SetTooltip(Locs.InstallPlugin); } - + ImGui.SetCursorPos(before); } @@ -487,10 +519,10 @@ internal class ProfileManagerWidget ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30)); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); - var enabled = plugin.IsEnabled; - if (ImGui.Checkbox($"###{this.editingProfileGuid}-{plugin.InternalName}", ref enabled)) + var enabled = profileEntry.IsEnabled; + if (ImGui.Checkbox($"###{this.editingProfileGuid}-{profileEntry.InternalName}", ref enabled)) { - Task.Run(() => profile.AddOrUpdateAsync(plugin.InternalName, enabled)) + Task.Run(() => profile.AddOrUpdateAsync(profileEntry.WorkingPluginId, profileEntry.InternalName, enabled)) .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotChangeState); } @@ -498,19 +530,19 @@ internal class ProfileManagerWidget ImGui.SetCursorPosX(windowSize.X - (ImGuiHelpers.GlobalScale * 30 * btnOffset) - 5); ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (pluginLineHeight / 2) - (ImGui.GetFrameHeight() / 2)); - if (ImGuiComponents.IconButton($"###removePlugin{plugin.InternalName}", FontAwesomeIcon.Trash)) + if (ImGuiComponents.IconButton($"###removePlugin{profileEntry.InternalName}", FontAwesomeIcon.Trash)) { - wantRemovePluginInternalName = plugin.InternalName; + wantRemovePluginGuid = profileEntry.WorkingPluginId; } if (ImGui.IsItemHovered()) ImGui.SetTooltip(Locs.RemovePlugin); } - if (wantRemovePluginInternalName != null) + if (wantRemovePluginGuid != null) { // TODO: handle error - Task.Run(() => profile.RemoveAsync(wantRemovePluginInternalName, false)) + Task.Run(() => profile.RemoveAsync(wantRemovePluginGuid.Value, false)) .ContinueWith(this.installer.DisplayErrorContinuation, Locs.ErrorCouldNotRemove); } diff --git a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs index 7d4489f8d..c325028e1 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/SettingsWindow.cs @@ -5,10 +5,10 @@ using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Internal.Windows.Settings.Tabs; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; -using Dalamud.Plugin.Internal; using Dalamud.Utility; using ImGuiNET; @@ -19,14 +19,7 @@ namespace Dalamud.Interface.Internal.Windows.Settings; /// internal class SettingsWindow : Window { - private readonly SettingsTab[] tabs = - { - new SettingsTabGeneral(), - new SettingsTabLook(), - new SettingsTabDtr(), - new SettingsTabExperimental(), - new SettingsTabAbout(), - }; + private SettingsTab[]? tabs; private string searchInput = string.Empty; @@ -49,6 +42,15 @@ internal class SettingsWindow : Window /// public override void OnOpen() { + this.tabs ??= new SettingsTab[] + { + new SettingsTabGeneral(), + new SettingsTabLook(), + new SettingsTabDtr(), + new SettingsTabExperimental(), + new SettingsTabAbout(), + }; + foreach (var settingsTab in this.tabs) { settingsTab.Load(); @@ -64,15 +66,13 @@ internal class SettingsWindow : Window { var configuration = Service.Get(); var interfaceManager = Service.Get(); + var fontAtlasFactory = Service.Get(); - var rebuildFont = - ImGui.GetIO().FontGlobalScale != configuration.GlobalUiScale || - interfaceManager.FontGamma != configuration.FontGammaLevel || - interfaceManager.UseAxis != configuration.UseAxisFontsFromGame; + var rebuildFont = fontAtlasFactory.UseAxis != configuration.UseAxisFontsFromGame; + rebuildFont |= !Equals(ImGui.GetIO().FontGlobalScale, configuration.GlobalUiScale); ImGui.GetIO().FontGlobalScale = configuration.GlobalUiScale; - interfaceManager.FontGammaOverride = null; - interfaceManager.UseAxisOverride = null; + fontAtlasFactory.UseAxisOverride = null; if (rebuildFont) interfaceManager.RebuildFonts(); diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs index 5b6f6b02f..8714fd666 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabAbout.cs @@ -1,13 +1,13 @@ -using System; -using System.Diagnostics; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Numerics; using CheapLoc; using Dalamud.Game.Gui; using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Internal; @@ -15,7 +15,6 @@ using Dalamud.Storage.Assets; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.UI; using ImGuiNET; -using ImGuiScene; namespace Dalamud.Interface.Internal.Windows.Settings.Tabs; @@ -173,16 +172,21 @@ Contribute at: https://github.com/goatcorp/Dalamud "; private readonly Stopwatch creditsThrottler; + private readonly IFontAtlas privateAtlas; private string creditsText; private bool resetNow = false; private IDalamudTextureWrap? logoTexture; - private GameFontHandle? thankYouFont; + private IFontHandle? thankYouFont; public SettingsTabAbout() { this.creditsThrottler = new(); + + this.privateAtlas = Service + .Get() + .CreateFontAtlas(nameof(SettingsTabAbout), FontAtlasAutoRebuildMode.Async); } public override SettingsEntry[] Entries { get; } = { }; @@ -207,11 +211,7 @@ Contribute at: https://github.com/goatcorp/Dalamud this.creditsThrottler.Restart(); - if (this.thankYouFont == null) - { - var gfm = Service.Get(); - this.thankYouFont = gfm.NewFontRef(new GameFontStyle(GameFontFamilyAndSize.TrumpGothic34)); - } + this.thankYouFont ??= this.privateAtlas.NewGameFontHandle(new(GameFontFamilyAndSize.TrumpGothic34)); this.resetNow = true; @@ -269,14 +269,12 @@ Contribute at: https://github.com/goatcorp/Dalamud if (this.thankYouFont != null) { - ImGui.PushFont(this.thankYouFont.ImFont); + using var fontPush = this.thankYouFont.Push(); var thankYouLenX = ImGui.CalcTextSize(ThankYouText).X; ImGui.Dummy(new Vector2((windowX / 2) - (thankYouLenX / 2), 0f)); ImGui.SameLine(); ImGui.TextUnformatted(ThankYouText); - - ImGui.PopFont(); } ImGuiHelpers.ScaledDummy(0, windowSize.Y + 50f); @@ -305,9 +303,5 @@ Contribute at: https://github.com/goatcorp/Dalamud /// /// Disposes of managed and unmanaged resources. /// - public override void Dispose() - { - this.logoTexture?.Dispose(); - this.thankYouFont?.Dispose(); - } + public override void Dispose() => this.privateAtlas.Dispose(); } diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs index 02e8ce789..5293e13c4 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs @@ -1,12 +1,14 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; +using System.Text; using CheapLoc; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.Settings.Widgets; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Utility; using ImGuiNET; @@ -28,7 +30,6 @@ public class SettingsTabLook : SettingsTab }; private float globalUiScale; - private float fontGamma; public override SettingsEntry[] Entries { get; } = { @@ -41,9 +42,8 @@ public class SettingsTabLook : SettingsTab (v, c) => c.UseAxisFontsFromGame = v, v => { - var im = Service.Get(); - im.UseAxisOverride = v; - im.RebuildFonts(); + Service.Get().UseAxisOverride = v; + Service.Get().RebuildFonts(); }), new GapSettingsEntry(5, true), @@ -145,6 +145,7 @@ public class SettingsTabLook : SettingsTab public override void Draw() { var interfaceManager = Service.Get(); + var fontBuildTask = interfaceManager.FontBuildTask; ImGui.AlignTextToFramePadding(); ImGui.Text(Loc.Localize("DalamudSettingsGlobalUiScale", "Global Font Scale")); @@ -164,6 +165,19 @@ public class SettingsTabLook : SettingsTab } } + if (!fontBuildTask.IsCompleted) + { + ImGui.SameLine(); + var buildingFonts = Loc.Localize("DalamudSettingsFontBuildInProgressWithEndingThreeDots", "Building fonts..."); + unsafe + { + var len = Encoding.UTF8.GetByteCount(buildingFonts); + var p = stackalloc byte[len]; + Encoding.UTF8.GetBytes(buildingFonts, new(p, len)); + ImGuiNative.igTextUnformatted(p, (p + len + ((Environment.TickCount / 200) % 3)) - 2); + } + } + var globalUiScaleInPt = 12f * this.globalUiScale; if (ImGui.DragFloat("##DalamudSettingsGlobalUiScaleDrag", ref globalUiScaleInPt, 0.1f, 9.6f, 36f, "%.1fpt", ImGuiSliderFlags.AlwaysClamp)) { @@ -174,33 +188,25 @@ public class SettingsTabLook : SettingsTab ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsGlobalUiScaleHint", "Scale text in all XIVLauncher UI elements - this is useful for 4K displays.")); - ImGuiHelpers.ScaledDummy(5); - - ImGui.AlignTextToFramePadding(); - ImGui.Text(Loc.Localize("DalamudSettingsFontGamma", "Font Gamma")); - ImGui.SameLine(); - if (ImGui.Button(Loc.Localize("DalamudSettingsIndividualConfigResetToDefaultValue", "Reset") + "##DalamudSettingsFontGammaReset")) + if (fontBuildTask.IsFaulted || fontBuildTask.IsCanceled) { - this.fontGamma = 1.4f; - interfaceManager.FontGammaOverride = this.fontGamma; - interfaceManager.RebuildFonts(); + ImGui.TextColored( + ImGuiColors.DalamudRed, + Loc.Localize("DalamudSettingsFontBuildFaulted", "Failed to load fonts as requested.")); + if (fontBuildTask.Exception is not null + && ImGui.CollapsingHeader("##DalamudSetingsFontBuildFaultReason")) + { + foreach (var e in fontBuildTask.Exception.InnerExceptions) + ImGui.TextUnformatted(e.ToString()); + } } - if (ImGui.DragFloat("##DalamudSettingsFontGammaDrag", ref this.fontGamma, 0.005f, 0.3f, 3f, "%.2f", ImGuiSliderFlags.AlwaysClamp)) - { - interfaceManager.FontGammaOverride = this.fontGamma; - interfaceManager.RebuildFonts(); - } - - ImGuiHelpers.SafeTextColoredWrapped(ImGuiColors.DalamudGrey, Loc.Localize("DalamudSettingsFontGammaHint", "Changes the thickness of text.")); - base.Draw(); } public override void Load() { this.globalUiScale = Service.Get().GlobalUiScale; - this.fontGamma = Service.Get().FontGammaLevel; base.Load(); } diff --git a/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs b/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs index 85f8a826f..c8cc1f42c 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Widgets/LanguageChooserSettingsEntry.cs @@ -31,17 +31,20 @@ public sealed class LanguageChooserSettingsEntry : SettingsEntry try { var locLanguagesList = new List(); - string locLanguage; foreach (var language in this.languages) { - if (language != "ko") + switch (language) { - locLanguage = CultureInfo.GetCultureInfo(language).NativeName; - locLanguagesList.Add(char.ToUpper(locLanguage[0]) + locLanguage[1..]); - } - else - { - locLanguagesList.Add("Korean"); + case "ko": + locLanguagesList.Add("Korean"); + break; + case "tw": + locLanguagesList.Add("中華民國國語"); + break; + default: + string locLanguage = CultureInfo.GetCultureInfo(language).NativeName; + locLanguagesList.Add(char.ToUpper(locLanguage[0]) + locLanguage[1..]); + break; } } diff --git a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs index 42bca89ff..9c385a99c 100644 --- a/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs +++ b/Dalamud/Interface/Internal/Windows/TitleScreenMenuWindow.cs @@ -7,11 +7,14 @@ using Dalamud.Game; using Dalamud.Game.ClientState; using Dalamud.Game.Gui; using Dalamud.Interface.Animation.EasingFunctions; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Services; using Dalamud.Storage.Assets; +using Dalamud.Utility; using ImGuiNET; @@ -27,16 +30,17 @@ internal class TitleScreenMenuWindow : Window, IDisposable private readonly ClientState clientState; private readonly DalamudConfiguration configuration; - private readonly Framework framework; private readonly GameGui gameGui; private readonly TitleScreenMenu titleScreenMenu; + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly IFontAtlas privateAtlas; + private readonly Lazy myFontHandle; private readonly Lazy shadeTexture; private readonly Dictionary shadeEasings = new(); private readonly Dictionary moveEasings = new(); private readonly Dictionary logoEasings = new(); - private readonly Dictionary specialGlyphRequests = new(); private InOutCubic? fadeOutEasing; @@ -48,6 +52,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// An instance of . /// An instance of . /// An instance of . + /// An instance of . /// An instance of . /// An instance of . /// An instance of . @@ -55,6 +60,7 @@ internal class TitleScreenMenuWindow : Window, IDisposable ClientState clientState, DalamudConfiguration configuration, DalamudAssetManager dalamudAssetManager, + FontAtlasFactory fontAtlasFactory, Framework framework, GameGui gameGui, TitleScreenMenu titleScreenMenu) @@ -65,7 +71,6 @@ internal class TitleScreenMenuWindow : Window, IDisposable { this.clientState = clientState; this.configuration = configuration; - this.framework = framework; this.gameGui = gameGui; this.titleScreenMenu = titleScreenMenu; @@ -77,9 +82,25 @@ internal class TitleScreenMenuWindow : Window, IDisposable this.PositionCondition = ImGuiCond.Always; this.RespectCloseHotkey = false; + this.shadeTexture = new(() => dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade)); + this.privateAtlas = fontAtlasFactory.CreateFontAtlas(this.WindowName, FontAtlasAutoRebuildMode.Async); + this.scopedFinalizer.Add(this.privateAtlas); + + this.myFontHandle = new( + () => this.scopedFinalizer.Add( + this.privateAtlas.NewDelegateFontHandle( + e => e.OnPreBuild( + toolkit => toolkit.AddDalamudDefaultFont( + TargetFontSizePx, + titleScreenMenu.Entries.SelectMany(x => x.Name).ToGlyphRange()))))); + + titleScreenMenu.EntryListChange += this.TitleScreenMenuEntryListChange; + this.scopedFinalizer.Add(() => titleScreenMenu.EntryListChange -= this.TitleScreenMenuEntryListChange); + this.shadeTexture = new(() => dalamudAssetManager.GetDalamudTextureWrap(DalamudAsset.TitleScreenMenuShade)); framework.Update += this.FrameworkOnUpdate; + this.scopedFinalizer.Add(() => framework.Update -= this.FrameworkOnUpdate); } private enum State @@ -94,6 +115,9 @@ internal class TitleScreenMenuWindow : Window, IDisposable /// public bool AllowDrawing { get; set; } = true; + /// + public void Dispose() => this.scopedFinalizer.Dispose(); + /// public override void PreDraw() { @@ -109,12 +133,6 @@ internal class TitleScreenMenuWindow : Window, IDisposable base.PostDraw(); } - /// - public void Dispose() - { - this.framework.Update -= this.FrameworkOnUpdate; - } - /// public override void Draw() { @@ -246,33 +264,12 @@ internal class TitleScreenMenuWindow : Window, IDisposable break; } } - - var srcText = entries.Select(e => e.Name).ToHashSet(); - var keys = this.specialGlyphRequests.Keys.ToHashSet(); - keys.RemoveWhere(x => srcText.Contains(x)); - foreach (var key in keys) - { - this.specialGlyphRequests[key].Dispose(); - this.specialGlyphRequests.Remove(key); - } } private bool DrawEntry( TitleScreenMenuEntry entry, bool inhibitFadeout, bool showText, bool isFirst, bool overrideAlpha, bool interactable) { - InterfaceManager.SpecialGlyphRequest fontHandle; - if (this.specialGlyphRequests.TryGetValue(entry.Name, out fontHandle) && fontHandle.Size != TargetFontSizePx) - { - fontHandle.Dispose(); - this.specialGlyphRequests.Remove(entry.Name); - fontHandle = null; - } - - if (fontHandle == null) - this.specialGlyphRequests[entry.Name] = fontHandle = Service.Get().NewFontSizeRef(TargetFontSizePx, entry.Name); - - ImGui.PushFont(fontHandle.Font); - ImGui.SetWindowFontScale(TargetFontSizePx / fontHandle.Size); + using var fontScopeDispose = this.myFontHandle.Value.Push(); var scale = ImGui.GetIO().FontGlobalScale; @@ -383,8 +380,6 @@ internal class TitleScreenMenuWindow : Window, IDisposable initialCursor.Y += entry.Texture.Height * scale; ImGui.SetCursorPos(initialCursor); - ImGui.PopFont(); - return isHover; } @@ -401,4 +396,6 @@ internal class TitleScreenMenuWindow : Window, IDisposable if (charaMake != IntPtr.Zero || charaSelect != IntPtr.Zero || titleDcWorldMap != IntPtr.Zero) this.IsOpen = false; } + + private void TitleScreenMenuEntryListChange() => this.privateAtlas.BuildFontsAsync(); } diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasAutoRebuildMode.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasAutoRebuildMode.cs new file mode 100644 index 000000000..50e591390 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasAutoRebuildMode.cs @@ -0,0 +1,22 @@ +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// How to rebuild . +/// +public enum FontAtlasAutoRebuildMode +{ + /// + /// Do not rebuild. + /// + Disable, + + /// + /// Rebuild on new frame. + /// + OnNewFrame, + + /// + /// Rebuild asynchronously. + /// + Async, +} diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStep.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStep.cs new file mode 100644 index 000000000..dcfcc32e3 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStep.cs @@ -0,0 +1,30 @@ +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Build step for . +/// +public enum FontAtlasBuildStep +{ + // Note: leave 0 alone; make default(FontAtlasBuildStep) not have a valid value + + /// + /// Called before calling .
+ /// Expect to be passed.
+ /// When called from , this will be called before the delegates + /// passed to . + ///
+ PreBuild = 1, + + /// + /// Called after calling .
+ /// Expect to be passed.
+ /// When called from , this will be called after the delegates + /// passed to ; you can do cross-font operations here.
+ ///
+ /// This callback is not guaranteed to happen after , + /// but it will never happen on its own. + ///
+ PostBuild = 2, +} diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStepDelegate.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStepDelegate.cs new file mode 100644 index 000000000..2ed88102f --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildStepDelegate.cs @@ -0,0 +1,14 @@ +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Delegate to be called when a font needs to be built. +/// +/// A toolkit that may help you for font building steps. +/// +/// An implementation of may implement all of +/// and .
+/// Either use to identify the build step, or use +/// and +/// for routing. +///
+public delegate void FontAtlasBuildStepDelegate(IFontAtlasBuildToolkit toolkit); diff --git a/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildToolkitUtilities.cs b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildToolkitUtilities.cs new file mode 100644 index 000000000..4c3e9023a --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/FontAtlasBuildToolkitUtilities.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Convenience function for building fonts through . +/// +public static class FontAtlasBuildToolkitUtilities +{ + /// + /// Compiles given s into an array of containing ImGui glyph ranges. + /// + /// The chars. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// The compiled range. + public static ushort[] ToGlyphRange( + this IEnumerable enumerable, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) + { + using var builderScoped = ImGuiHelpers.NewFontGlyphRangeBuilderPtrScoped(out var builder); + foreach (var c in enumerable) + builder.AddChar(c); + return builder.BuildRangesToArray(addFallbackCodepoints, addEllipsisCodepoints); + } + + /// + /// Compiles given s into an array of containing ImGui glyph ranges. + /// + /// The chars. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// The compiled range. + public static ushort[] ToGlyphRange( + this ReadOnlySpan span, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) + { + using var builderScoped = ImGuiHelpers.NewFontGlyphRangeBuilderPtrScoped(out var builder); + foreach (var c in span) + builder.AddChar(c); + return builder.BuildRangesToArray(addFallbackCodepoints, addEllipsisCodepoints); + } + + /// + /// Compiles given string into an array of containing ImGui glyph ranges. + /// + /// The string. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// The compiled range. + public static ushort[] ToGlyphRange( + this string @string, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) => + @string.AsSpan().ToGlyphRange(addFallbackCodepoints, addEllipsisCodepoints); + + /// + /// Finds the corresponding in + /// . that corresponds to the + /// specified font . + /// + /// The toolkit. + /// The font. + /// The relevant config pointer, or empty config pointer if not found. + public static unsafe ImFontConfigPtr FindConfigPtr(this IFontAtlasBuildToolkit toolkit, ImFontPtr fontPtr) + { + foreach (ref var c in toolkit.NewImAtlas.ConfigDataWrapped().DataSpan) + { + if (c.DstFont == fontPtr.NativePtr) + return new((nint)Unsafe.AsPointer(ref c)); + } + + return default; + } + + /// + /// Invokes + /// if of + /// is . + /// + /// The toolkit. + /// The action. + /// This, for method chaining. + public static IFontAtlasBuildToolkit OnPreBuild( + this IFontAtlasBuildToolkit toolkit, + Action action) + { + if (toolkit.BuildStep is FontAtlasBuildStep.PreBuild) + action.Invoke((IFontAtlasBuildToolkitPreBuild)toolkit); + return toolkit; + } + + /// + /// Invokes + /// if of + /// is . + /// + /// The toolkit. + /// The action. + /// toolkit, for method chaining. + public static IFontAtlasBuildToolkit OnPostBuild( + this IFontAtlasBuildToolkit toolkit, + Action action) + { + if (toolkit.BuildStep is FontAtlasBuildStep.PostBuild) + action.Invoke((IFontAtlasBuildToolkitPostBuild)toolkit); + return toolkit; + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs new file mode 100644 index 000000000..a9c21f94e --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs @@ -0,0 +1,143 @@ +using System.Threading.Tasks; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Wrapper for . +/// +public interface IFontAtlas : IDisposable +{ + /// + /// Event to be called on build step changes.
+ /// is meaningless for this event. + ///
+ event FontAtlasBuildStepDelegate? BuildStepChange; + + /// + /// Event fired when a font rebuild operation is recommended.
+ /// This event will be invoked from the main thread.
+ ///
+ /// Reasons for the event include changes in and + /// initialization of new associated font handles. + ///
+ /// + /// You should call or + /// if is not set to true.
+ /// Avoid calling here; it will block the main thread. + ///
+ event Action? RebuildRecommend; + + /// + /// Gets the name of the atlas. For logging and debugging purposes. + /// + string Name { get; } + + /// + /// Gets a value how the atlas should be rebuilt when the relevant Dalamud Configuration changes. + /// + FontAtlasAutoRebuildMode AutoRebuildMode { get; } + + /// + /// Gets the font atlas. Might be empty. + /// + ImFontAtlasPtr ImAtlas { get; } + + /// + /// Gets the task that represents the current font rebuild state. + /// + Task BuildTask { get; } + + /// + /// Gets a value indicating whether there exists any built atlas, regardless of . + /// + bool HasBuiltAtlas { get; } + + /// + /// Gets a value indicating whether this font atlas is under the effect of global scale. + /// + bool IsGlobalScaled { get; } + + /// + /// Suppresses automatically rebuilding fonts for the scope. + /// + /// An instance of that will release the suppression. + /// + /// Use when you will be creating multiple new handles, and want rebuild to trigger only when you're done doing so. + /// This function will effectively do nothing, if is set to + /// . + /// + /// + /// + /// using (atlas.SuppressBuild()) { + /// this.font1 = atlas.NewGameFontHandle(...); + /// this.font2 = atlas.NewDelegateFontHandle(...); + /// } + /// + /// + public IDisposable SuppressAutoRebuild(); + + /// + /// Creates a new from game's built-in fonts. + /// + /// Font to use. + /// Handle to a font that may or may not be ready yet. + public IFontHandle NewGameFontHandle(GameFontStyle style); + + /// + /// Creates a new IFontHandle using your own callbacks. + /// + /// Callback for . + /// Handle to a font that may or may not be ready yet. + /// + /// On initialization: + /// + /// this.fontHandle = atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { + /// var config = new SafeFontConfig { SizePx = 16 }; + /// config.MergeFont = tk.AddFontFromFile(@"C:\Windows\Fonts\comic.ttf", config); + /// tk.AddGameSymbol(config); + /// tk.AddExtraGlyphsForDalamudLanguage(config); + /// // optionally do the following if you have to add more than one font here, + /// // to specify which font added during this delegate is the final font to use. + /// tk.Font = config.MergeFont; + /// })); + /// // or + /// this.fontHandle = atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => tk.AddDalamudDefaultFont(36))); + /// + ///
+ /// On use: + /// + /// using (this.fontHandle.Push()) + /// ImGui.TextUnformatted("Example"); + /// + ///
+ public IFontHandle NewDelegateFontHandle(FontAtlasBuildStepDelegate buildStepDelegate); + + /// + /// Queues rebuilding fonts, on the main thread.
+ /// Note that would not necessarily get changed from calling this function. + ///
+ /// If is . + /// + /// Using this method will block the main thread on rebuilding fonts, effectively calling + /// from the main thread. Consider migrating to . + /// + void BuildFontsOnNextFrame(); + + /// + /// Rebuilds fonts immediately, on the current thread. + /// + /// If is . + void BuildFontsImmediately(); + + /// + /// Rebuilds fonts asynchronously, on any thread. + /// + /// The task. + /// If is . + Task BuildFontsAsync(); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkit.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkit.cs new file mode 100644 index 000000000..f75ed4686 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkit.cs @@ -0,0 +1,91 @@ +using System.Runtime.InteropServices; + +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Common stuff for and . +/// +public interface IFontAtlasBuildToolkit +{ + /// + /// Functionalities for compatibility behavior.
+ ///
+ [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + internal interface IApi9Compat : IFontAtlasBuildToolkit + { + /// + /// Invokes , temporarily applying s.
+ ///
+ /// The action to invoke. + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public void FromUiBuilderObsoleteEventHandlers(Action action); + } + + /// + /// Gets or sets the font relevant to the call. + /// + ImFontPtr Font { get; set; } + + /// + /// Gets the current scale this font atlas is being built with. + /// + float Scale { get; } + + /// + /// Gets a value indicating whether the current build operation is asynchronous. + /// + bool IsAsyncBuildOperation { get; } + + /// + /// Gets the current build step. + /// + FontAtlasBuildStep BuildStep { get; } + + /// + /// Gets the font atlas being built. + /// + ImFontAtlasPtr NewImAtlas { get; } + + /// + /// Gets the wrapper for of .
+ /// This does not need to be disposed. Calling does nothing.- + ///
+ /// Modification of this vector may result in undefined behaviors. + ///
+ ImVectorWrapper Fonts { get; } + + /// + /// Queues an item to be disposed after the native atlas gets disposed, successful or not. + /// + /// Disposable type. + /// The disposable. + /// The same . + T DisposeWithAtlas(T disposable) where T : IDisposable; + + /// + /// Queues an item to be disposed after the native atlas gets disposed, successful or not. + /// + /// The gc handle. + /// The same . + GCHandle DisposeWithAtlas(GCHandle gcHandle); + + /// + /// Queues an item to be disposed after the native atlas gets disposed, successful or not. + /// + /// The action to run on dispose. + void DisposeWithAtlas(Action action); + + /// + /// Gets the instance of corresponding to + /// from . + /// + /// The font handle. + /// The corresonding , or default if not found. + ImFontPtr GetFont(IFontHandle fontHandle); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPostBuild.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPostBuild.cs new file mode 100644 index 000000000..eb7c7e08c --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPostBuild.cs @@ -0,0 +1,50 @@ +using Dalamud.Interface.Internal; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Toolkit for use when the build state is . +/// +public interface IFontAtlasBuildToolkitPostBuild : IFontAtlasBuildToolkit +{ + /// + /// Gets whether global scaling is ignored for the given font. + /// + /// The font. + /// True if ignored. + bool IsGlobalScaleIgnored(ImFontPtr fontPtr); + + /// + /// Stores a texture to be managed with the atlas. + /// + /// The texture wrap. + /// Dispose the wrap on error. + /// The texture index. + int StoreTexture(IDalamudTextureWrap textureWrap, bool disposeOnError); + + /// + /// Copies glyphs across fonts, in a safer way.
+ /// If the font does not belong to the current atlas, this function is a no-op. + ///
+ /// Source font. + /// Target font. + /// Whether to copy missing glyphs only. + /// Whether to call target.BuildLookupTable(). + /// Low codepoint range to copy. + /// High codepoing range to copy. + void CopyGlyphsAcrossFonts( + ImFontPtr source, + ImFontPtr target, + bool missingOnly, + bool rebuildLookupTable = true, + char rangeLow = ' ', + char rangeHigh = '\uFFFE'); + + /// + /// Calls , with some fixups. + /// + /// The font. + void BuildLookupTable(ImFontPtr font); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPreBuild.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPreBuild.cs new file mode 100644 index 000000000..38d8d2fe8 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlasBuildToolkitPreBuild.cs @@ -0,0 +1,188 @@ +using System.IO; +using System.Runtime.InteropServices; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Toolkit for use when the build state is .
+///
+/// After returns, +/// either must be set, +/// or at least one font must have been added to the atlas using one of AddFont... functions. +///
+public interface IFontAtlasBuildToolkitPreBuild : IFontAtlasBuildToolkit +{ + /// + /// Queues an item to be disposed after the whole build process gets complete, successful or not. + /// + /// Disposable type. + /// The disposable. + /// The same . + T DisposeAfterBuild(T disposable) where T : IDisposable; + + /// + /// Queues an item to be disposed after the whole build process gets complete, successful or not. + /// + /// The gc handle. + /// The same . + GCHandle DisposeAfterBuild(GCHandle gcHandle); + + /// + /// Queues an item to be disposed after the whole build process gets complete, successful or not. + /// + /// The action to run on dispose. + void DisposeAfterBuild(Action action); + + /// + /// Excludes given font from global scaling. + /// + /// The font. + /// Same with . + ImFontPtr IgnoreGlobalScale(ImFontPtr fontPtr); + + /// + /// Gets whether global scaling is ignored for the given font. + /// + /// The font. + /// True if ignored. + bool IsGlobalScaleIgnored(ImFontPtr fontPtr); + + /// + /// Adds a font from memory region allocated using .
+ /// It WILL crash if you try to use a memory pointer allocated in some other way.
+ /// + /// Do NOT call on the once this function has + /// been called, unless is set and the function has thrown an error. + /// + ///
+ /// Memory address for the data allocated using . + /// The size of the font file.. + /// The font config. + /// Free if an exception happens. + /// A debug tag. + /// The newly added font. + unsafe ImFontPtr AddFontFromImGuiHeapAllocatedMemory( + nint dataPointer, + int dataSize, + in SafeFontConfig fontConfig, + bool freeOnException, + string debugTag) + => this.AddFontFromImGuiHeapAllocatedMemory( + (void*)dataPointer, + dataSize, + fontConfig, + freeOnException, + debugTag); + + /// + /// Adds a font from memory region allocated using .
+ /// It WILL crash if you try to use a memory pointer allocated in some other way.
+ /// + /// Do NOT call on the once this function has + /// been called, unless is set and the function has thrown an error. + /// + ///
+ /// Memory address for the data allocated using . + /// The size of the font file.. + /// The font config. + /// Free if an exception happens. + /// A debug tag. + /// The newly added font. + unsafe ImFontPtr AddFontFromImGuiHeapAllocatedMemory( + void* dataPointer, + int dataSize, + in SafeFontConfig fontConfig, + bool freeOnException, + string debugTag); + + /// + /// Adds a font from a file. + /// + /// The file path to create a new font from. + /// The font config. + /// The newly added font. + ImFontPtr AddFontFromFile(string path, in SafeFontConfig fontConfig); + + /// + /// Adds a font from a stream. + /// + /// The stream to create a new font from. + /// The font config. + /// Dispose when this function returns or throws. + /// A debug tag. + /// The newly added font. + ImFontPtr AddFontFromStream(Stream stream, in SafeFontConfig fontConfig, bool leaveOpen, string debugTag); + + /// + /// Adds a font from memory. + /// + /// The span to create from. + /// The font config. + /// A debug tag. + /// The newly added font. + ImFontPtr AddFontFromMemory(ReadOnlySpan span, in SafeFontConfig fontConfig, string debugTag); + + /// + /// Adds the default font known to the current font atlas.
+ ///
+ /// Includes and .
+ /// As this involves adding multiple fonts, calling this function will set + /// as the return value of this function, if it was empty before. + ///
+ /// Font size in pixels. + /// The glyph ranges. Use .ToGlyphRange to build. + /// A font returned from . + ImFontPtr AddDalamudDefaultFont(float sizePx, ushort[]? glyphRanges = null); + + /// + /// Adds a font that is shipped with Dalamud.
+ ///
+ /// Note: if game symbols font file is requested but is unavailable, + /// then it will take the glyphs from game's built-in fonts, and everything in + /// will be ignored but , , + /// and . + ///
+ /// The font type. + /// The font config. + /// The added font. + ImFontPtr AddDalamudAssetFont(DalamudAsset asset, in SafeFontConfig fontConfig); + + /// + /// Same with (, ...), + /// but using only FontAwesome icon ranges.
+ /// will be ignored. + ///
+ /// The font config. + /// The added font. + ImFontPtr AddFontAwesomeIconFont(in SafeFontConfig fontConfig); + + /// + /// Adds the game's symbols into the provided font.
+ /// will be ignored.
+ /// If the game symbol font file is unavailable, only will be honored. + ///
+ /// The font config. + /// The added font. + ImFontPtr AddGameSymbol(in SafeFontConfig fontConfig); + + /// + /// Adds the game glyphs to the font. + /// + /// The font style. + /// The glyph ranges. + /// The font to merge to. If empty, then a new font will be created. + /// The added font. + ImFontPtr AddGameGlyphs(GameFontStyle gameFontStyle, ushort[]? glyphRanges, ImFontPtr mergeFont); + + /// + /// Adds glyphs of extra languages into the provided font, depending on Dalamud Configuration.
+ /// will be ignored. + ///
+ /// The font config. + void AttachExtraGlyphsForDalamudLanguage(in SafeFontConfig fontConfig); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs new file mode 100644 index 000000000..11c26616b --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Represents a reference counting handle for fonts. +/// +public interface IFontHandle : IDisposable +{ + /// + /// Delegate for . + /// + /// The relevant font handle. + /// The locked font for this font handle, locked during the call of this delegate. + public delegate void ImFontChangedDelegate(IFontHandle fontHandle, ILockedImFont lockedFont); + + /// + /// Called when the built instance of has been changed.
+ /// This event can be invoked outside the main thread. + ///
+ event ImFontChangedDelegate ImFontChanged; + + /// + /// Gets the load exception, if it failed to load. Otherwise, it is null. + /// + Exception? LoadException { get; } + + /// + /// Gets a value indicating whether this font is ready for use. + /// + /// + /// Use directly if you want to keep the current ImGui font if the font is not ready.
+ /// Alternatively, use to wait for this property to become true. + ///
+ bool Available { get; } + + /// + /// Locks the fully constructed instance of corresponding to the this + /// , for use in any thread.
+ /// Modification of the font will exhibit undefined behavior if some other thread also uses the font. + ///
+ /// An instance of that must be disposed after use. + /// + /// Calling . will not unlock the + /// locked by this function. + /// + /// If is false. + ILockedImFont Lock(); + + /// + /// Pushes the current font into ImGui font stack, if available.
+ /// Use to access the current font.
+ /// You may not access the font once you dispose this object. + ///
+ /// A disposable object that will pop the font on dispose. + /// If called outside of the main thread. + /// + /// This function uses , and may do extra things. + /// Use or to undo this operation. + /// Do not use . + /// + IDisposable Push(); + + /// + /// Pops the font pushed to ImGui using , cleaning up any extra information as needed. + /// + void Pop(); + + /// + /// Waits for to become true. + /// + /// A task containing this . + Task WaitAsync(); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/ILockedImFont.cs b/Dalamud/Interface/ManagedFontAtlas/ILockedImFont.cs new file mode 100644 index 000000000..9136d2723 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/ILockedImFont.cs @@ -0,0 +1,21 @@ +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// The wrapper for , guaranteeing that the associated data will be available as long as +/// this struct is not disposed. +/// +public interface ILockedImFont : IDisposable +{ + /// + /// Gets the associated . + /// + ImFontPtr ImFont { get; } + + /// + /// Creates a new instance of with an additional reference to the owner. + /// + /// The new locked instance. + ILockedImFont NewRef(); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/DelegateFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/DelegateFontHandle.cs new file mode 100644 index 000000000..b13c60a53 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/DelegateFontHandle.cs @@ -0,0 +1,303 @@ +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// A font handle representing a user-callback generated font. +/// +internal sealed class DelegateFontHandle : FontHandle +{ + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// Callback for . + public DelegateFontHandle(IFontHandleManager manager, FontAtlasBuildStepDelegate callOnBuildStepChange) + : base(manager) + { + this.CallOnBuildStepChange = callOnBuildStepChange; + } + + /// + /// Gets the function to be called on build step changes. + /// + public FontAtlasBuildStepDelegate CallOnBuildStepChange { get; } + + /// + /// Manager for s. + /// + internal sealed class HandleManager : IFontHandleManager + { + private readonly HashSet handles = new(); + private readonly object syncRoot = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The name of the owner atlas. + public HandleManager(string atlasName) => this.Name = $"{atlasName}:{nameof(DelegateFontHandle)}:Manager"; + + /// + public event Action? RebuildRecommend; + + /// + public string Name { get; } + + /// + public IFontHandleSubstance? Substance { get; set; } + + /// + public void Dispose() + { + lock (this.syncRoot) + this.handles.Clear(); + } + + /// + public IFontHandle NewFontHandle(FontAtlasBuildStepDelegate buildStepDelegate) + { + var key = new DelegateFontHandle(this, buildStepDelegate); + lock (this.syncRoot) + this.handles.Add(key); + this.RebuildRecommend?.Invoke(); + return key; + } + + /// + public void FreeFontHandle(IFontHandle handle) + { + if (handle is not DelegateFontHandle cgfh) + return; + + lock (this.syncRoot) + this.handles.Remove(cgfh); + } + + /// + public IFontHandleSubstance NewSubstance(IRefCountable dataRoot) + { + lock (this.syncRoot) + return new HandleSubstance(this, dataRoot, this.handles.ToArray()); + } + } + + /// + /// Substance from . + /// + internal sealed class HandleSubstance : IFontHandleSubstance + { + private static readonly ModuleLog Log = new($"{nameof(DelegateFontHandle)}.{nameof(HandleSubstance)}"); + + // Owned by this class, but ImFontPtr values still do not belong to this. + private readonly Dictionary fonts = new(); + private readonly Dictionary buildExceptions = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The manager. + /// The data root. + /// The relevant handles. + public HandleSubstance( + IFontHandleManager manager, + IRefCountable dataRoot, + DelegateFontHandle[] relevantHandles) + { + // We do not call dataRoot.AddRef; this object is dependant on lifetime of dataRoot. + + this.Manager = manager; + this.DataRoot = dataRoot; + this.RelevantHandles = relevantHandles; + } + + /// + /// Gets the relevant handles. + /// + // Not owned by this class. Do not dispose. + public DelegateFontHandle[] RelevantHandles { get; } + + /// + ICollection IFontHandleSubstance.RelevantHandles => this.RelevantHandles; + + /// + public IRefCountable DataRoot { get; } + + /// + public IFontHandleManager Manager { get; } + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public IFontAtlasBuildToolkitPreBuild? PreBuildToolkitForApi9Compat { get; set; } + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public bool CreateFontOnAccess { get; set; } + + /// + public void Dispose() + { + this.fonts.Clear(); + this.buildExceptions.Clear(); + } + + /// + public ImFontPtr GetFontPtr(IFontHandle handle) => + handle is DelegateFontHandle cgfh ? this.fonts.GetValueOrDefault(cgfh) : default; + + /// + public Exception? GetBuildException(IFontHandle handle) => + handle is DelegateFontHandle cgfh ? this.buildExceptions.GetValueOrDefault(cgfh) : default; + + /// + public void OnPreBuild(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + var fontsVector = toolkitPreBuild.Fonts; + foreach (var k in this.RelevantHandles) + { + var fontCountPrevious = fontsVector.Length; + + try + { + toolkitPreBuild.Font = default; + k.CallOnBuildStepChange(toolkitPreBuild); + if (toolkitPreBuild.Font.IsNull()) + { + if (fontCountPrevious == fontsVector.Length) + { + throw new InvalidOperationException( + $"{nameof(FontAtlasBuildStepDelegate)} must either set the " + + $"{nameof(IFontAtlasBuildToolkitPreBuild.Font)} property, or add at least one font."); + } + + toolkitPreBuild.Font = fontsVector[^1]; + } + else + { + var found = false; + unsafe + { + for (var i = fontCountPrevious; !found && i < fontsVector.Length; i++) + { + if (fontsVector[i].NativePtr == toolkitPreBuild.Font.NativePtr) + found = true; + } + } + + if (!found) + { + throw new InvalidOperationException( + "The font does not exist in the atlas' font array. If you need an empty font, try" + + "adding Noto Sans from Dalamud Assets, but using new ushort[]{ ' ', ' ', 0 } as the" + + "glyph range."); + } + } + + if (fontsVector.Length - fontCountPrevious != 1) + { + Log.Warning( + "[{name}:Substance] {n} fonts added from {delegate} PreBuild call; " + + "Using the most recently added font. " + + "Did you mean to use {sfd}.{sfdprop} or {ifcp}.{ifcpprop}?", + this.Manager.Name, + fontsVector.Length - fontCountPrevious, + nameof(FontAtlasBuildStepDelegate), + nameof(SafeFontConfig), + nameof(SafeFontConfig.MergeFont), + nameof(ImFontConfigPtr), + nameof(ImFontConfigPtr.MergeMode)); + } + + for (var i = fontCountPrevious; i < fontsVector.Length; i++) + { + if (fontsVector[i].ValidateUnsafe() is { } ex) + { + throw new InvalidOperationException( + "One of the newly added fonts seem to be pointing to an invalid memory address.", + ex); + } + } + + // Check for duplicate entries; duplicates will result in free-after-free + for (var i = 0; i < fontCountPrevious; i++) + { + for (var j = fontCountPrevious; j < fontsVector.Length; j++) + { + unsafe + { + if (fontsVector[i].NativePtr == fontsVector[j].NativePtr) + throw new InvalidOperationException("An already added font has been added again."); + } + } + } + + this.fonts[k] = toolkitPreBuild.Font; + } + catch (Exception e) + { + this.fonts[k] = default; + this.buildExceptions[k] = e; + + Log.Error( + e, + "[{name}:Substance] An error has occurred while during {delegate} PreBuild call.", + this.Manager.Name, + nameof(FontAtlasBuildStepDelegate)); + + // Sanitization, in a futile attempt to prevent crashes on invalid parameters + unsafe + { + var distinct = + fontsVector + .DistinctBy(x => (nint)x.NativePtr) // Remove duplicates + .Where(x => x.ValidateUnsafe() is null) // Remove invalid entries without freeing them + .ToArray(); + + // We're adding the contents back; do not destroy the contents + fontsVector.Clear(true); + fontsVector.AddRange(distinct.AsSpan()); + } + } + } + } + + /// + public void OnPreBuildCleanup(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + // irrelevant + } + + /// + public void OnPostBuild(IFontAtlasBuildToolkitPostBuild toolkitPostBuild) + { + foreach (var k in this.RelevantHandles) + { + if (!this.fonts[k].IsNotNullAndLoaded()) + continue; + + try + { + toolkitPostBuild.Font = this.fonts[k]; + k.CallOnBuildStepChange.Invoke(toolkitPostBuild); + } + catch (Exception e) + { + this.fonts[k] = default; + this.buildExceptions[k] = e; + + Log.Error( + e, + "[{name}] An error has occurred while during {delegate} PostBuild call.", + this.Manager.Name, + nameof(FontAtlasBuildStepDelegate)); + } + } + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.BuildToolkit.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.BuildToolkit.cs new file mode 100644 index 000000000..e2b096701 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.BuildToolkit.cs @@ -0,0 +1,680 @@ +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Unicode; + +using Dalamud.Configuration.Internal; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Storage.Assets; +using Dalamud.Utility; + +using ImGuiNET; + +using SharpDX.DXGI; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Standalone font atlas. +/// +internal sealed partial class FontAtlasFactory +{ + private static readonly Dictionary> PairAdjustmentsCache = + new(); + + /// + /// Implementations for and + /// . + /// + private class BuildToolkit : IFontAtlasBuildToolkit.IApi9Compat, IFontAtlasBuildToolkitPreBuild, IFontAtlasBuildToolkitPostBuild, IDisposable + { + private static readonly ushort FontAwesomeIconMin = + (ushort)Enum.GetValues().Where(x => x > 0).Min(); + + private static readonly ushort FontAwesomeIconMax = + (ushort)Enum.GetValues().Where(x => x > 0).Max(); + + private readonly DisposeSafety.ScopedFinalizer disposeAfterBuild = new(); + private readonly GamePrebakedFontHandle.HandleSubstance gameFontHandleSubstance; + private readonly FontAtlasFactory factory; + private readonly FontAtlasBuiltData data; + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// New atlas. + /// An instance of . + /// Specify whether the current build operation is an asynchronous one. + public BuildToolkit( + FontAtlasFactory factory, + FontAtlasBuiltData data, + GamePrebakedFontHandle.HandleSubstance gameFontHandleSubstance, + bool isAsync) + { + this.data = data; + this.gameFontHandleSubstance = gameFontHandleSubstance; + this.IsAsyncBuildOperation = isAsync; + this.factory = factory; + } + + /// + public ImFontPtr Font { get; set; } + + /// + public float Scale => this.data.Scale; + + /// + public bool IsAsyncBuildOperation { get; } + + /// + public FontAtlasBuildStep BuildStep { get; set; } + + /// + public ImFontAtlasPtr NewImAtlas => this.data.Atlas; + + /// + public ImVectorWrapper Fonts => this.data.Fonts; + + /// + /// Gets the list of fonts to ignore global scale. + /// + public List GlobalScaleExclusions { get; } = new(); + + /// + public void Dispose() => this.disposeAfterBuild.Dispose(); + + /// + public T2 DisposeAfterBuild(T2 disposable) where T2 : IDisposable => + this.disposeAfterBuild.Add(disposable); + + /// + public GCHandle DisposeAfterBuild(GCHandle gcHandle) => this.disposeAfterBuild.Add(gcHandle); + + /// + public void DisposeAfterBuild(Action action) => this.disposeAfterBuild.Add(action); + + /// + public T DisposeWithAtlas(T disposable) where T : IDisposable => this.data.Garbage.Add(disposable); + + /// + public GCHandle DisposeWithAtlas(GCHandle gcHandle) => this.data.Garbage.Add(gcHandle); + + /// + public void DisposeWithAtlas(Action action) => this.data.Garbage.Add(action); + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public void FromUiBuilderObsoleteEventHandlers(Action action) + { + var previousSubstances = new IFontHandleSubstance[this.data.Substances.Count]; + for (var i = 0; i < previousSubstances.Length; i++) + { + previousSubstances[i] = this.data.Substances[i].Manager.Substance; + this.data.Substances[i].Manager.Substance = this.data.Substances[i]; + this.data.Substances[i].CreateFontOnAccess = true; + this.data.Substances[i].PreBuildToolkitForApi9Compat = this; + } + + try + { + action(); + } + finally + { + for (var i = 0; i < previousSubstances.Length; i++) + { + this.data.Substances[i].Manager.Substance = previousSubstances[i]; + this.data.Substances[i].CreateFontOnAccess = false; + this.data.Substances[i].PreBuildToolkitForApi9Compat = null; + } + } + } + + /// + public ImFontPtr GetFont(IFontHandle fontHandle) + { + foreach (var s in this.data.Substances) + { + var f = s.GetFontPtr(fontHandle); + if (!f.IsNull()) + return f; + } + + return default; + } + + /// + public ImFontPtr IgnoreGlobalScale(ImFontPtr fontPtr) + { + this.GlobalScaleExclusions.Add(fontPtr); + return fontPtr; + } + + /// + public bool IsGlobalScaleIgnored(ImFontPtr fontPtr) => + this.GlobalScaleExclusions.Contains(fontPtr); + + /// + public int StoreTexture(IDalamudTextureWrap textureWrap, bool disposeOnError) => + this.data.AddNewTexture(textureWrap, disposeOnError); + + /// + public unsafe ImFontPtr AddFontFromImGuiHeapAllocatedMemory( + void* dataPointer, + int dataSize, + in SafeFontConfig fontConfig, + bool freeOnException, + string debugTag) + { + Log.Verbose( + "[{name}] 0x{atlas:X}: {funcname}(0x{dataPointer:X}, 0x{dataSize:X}, ...) from {tag}", + this.data.Owner?.Name ?? "(error)", + (nint)this.NewImAtlas.NativePtr, + nameof(this.AddFontFromImGuiHeapAllocatedMemory), + (nint)dataPointer, + dataSize, + debugTag); + + try + { + fontConfig.ThrowOnInvalidValues(); + + var raw = fontConfig.Raw with + { + FontData = dataPointer, + FontDataSize = dataSize, + }; + + if (fontConfig.GlyphRanges is not { Length: > 0 } ranges) + ranges = new ushort[] { 1, 0xFFFE, 0 }; + + raw.GlyphRanges = (ushort*)this.DisposeAfterBuild( + GCHandle.Alloc(ranges, GCHandleType.Pinned)).AddrOfPinnedObject(); + + TrueTypeUtils.CheckImGuiCompatibleOrThrow(raw); + + var font = this.NewImAtlas.AddFont(&raw); + + var dataHash = default(HashCode); + dataHash.AddBytes(new(dataPointer, dataSize)); + var hashIdent = (uint)dataHash.ToHashCode() | ((ulong)dataSize << 32); + + List<(char Left, char Right, float Distance)> pairAdjustments; + lock (PairAdjustmentsCache) + { + if (!PairAdjustmentsCache.TryGetValue(hashIdent, out pairAdjustments)) + { + PairAdjustmentsCache.Add(hashIdent, pairAdjustments = new()); + try + { + pairAdjustments.AddRange(TrueTypeUtils.ExtractHorizontalPairAdjustments(raw).ToArray()); + } + catch + { + // don't care + } + } + } + + foreach (var pair in pairAdjustments) + { + if (!ImGuiHelpers.IsCodepointInSuppliedGlyphRangesUnsafe(pair.Left, raw.GlyphRanges)) + continue; + if (!ImGuiHelpers.IsCodepointInSuppliedGlyphRangesUnsafe(pair.Right, raw.GlyphRanges)) + continue; + + font.AddKerningPair(pair.Left, pair.Right, pair.Distance * raw.SizePixels); + } + + return font; + } + catch + { + if (freeOnException) + ImGuiNative.igMemFree(dataPointer); + throw; + } + } + + /// + public ImFontPtr AddFontFromFile(string path, in SafeFontConfig fontConfig) + { + return this.AddFontFromStream( + File.OpenRead(path), + fontConfig, + false, + $"{nameof(this.AddFontFromFile)}({path})"); + } + + /// + public unsafe ImFontPtr AddFontFromStream( + Stream stream, + in SafeFontConfig fontConfig, + bool leaveOpen, + string debugTag) + { + using var streamCloser = leaveOpen ? null : stream; + if (!stream.CanSeek) + { + // There is no need to dispose a MemoryStream. + var ms = new MemoryStream(); + stream.CopyTo(ms); + stream = ms; + } + + var length = checked((int)(uint)stream.Length); + var memory = ImGuiHelpers.AllocateMemory(length); + try + { + stream.ReadExactly(new(memory, length)); + return this.AddFontFromImGuiHeapAllocatedMemory( + memory, + length, + fontConfig, + false, + $"{nameof(this.AddFontFromStream)}({debugTag})"); + } + catch + { + ImGuiNative.igMemFree(memory); + throw; + } + } + + /// + public unsafe ImFontPtr AddFontFromMemory( + ReadOnlySpan span, + in SafeFontConfig fontConfig, + string debugTag) + { + var length = span.Length; + var memory = ImGuiHelpers.AllocateMemory(length); + try + { + span.CopyTo(new(memory, length)); + return this.AddFontFromImGuiHeapAllocatedMemory( + memory, + length, + fontConfig, + false, + $"{nameof(this.AddFontFromMemory)}({debugTag})"); + } + catch + { + ImGuiNative.igMemFree(memory); + throw; + } + } + + /// + public ImFontPtr AddDalamudDefaultFont(float sizePx, ushort[]? glyphRanges) + { + ImFontPtr font; + glyphRanges ??= this.factory.DefaultGlyphRanges; + if (this.factory.UseAxis) + { + font = this.AddGameGlyphs(new(GameFontFamily.Axis, sizePx), glyphRanges, default); + } + else + { + font = this.AddDalamudAssetFont( + DalamudAsset.NotoSansJpMedium, + new() { SizePx = sizePx, GlyphRanges = glyphRanges }); + this.AddGameSymbol(new() { SizePx = sizePx, MergeFont = font }); + } + + this.AttachExtraGlyphsForDalamudLanguage(new() { SizePx = sizePx, MergeFont = font }); + if (this.Font.IsNull()) + this.Font = font; + return font; + } + + /// + public ImFontPtr AddDalamudAssetFont(DalamudAsset asset, in SafeFontConfig fontConfig) + { + if (asset.GetPurpose() != DalamudAssetPurpose.Font) + throw new ArgumentOutOfRangeException(nameof(asset), asset, "Must have the purpose of Font."); + + switch (asset) + { + case DalamudAsset.LodestoneGameSymbol when this.factory.HasGameSymbolsFontFile: + return this.factory.AddFont( + this, + asset, + fontConfig with + { + FontNo = 0, + SizePx = (fontConfig.SizePx * 3) / 2, + }); + + case DalamudAsset.LodestoneGameSymbol when !this.factory.HasGameSymbolsFontFile: + { + return this.AddGameGlyphs( + new(GameFontFamily.Axis, fontConfig.SizePx), + fontConfig.GlyphRanges, + fontConfig.MergeFont); + } + + default: + return this.factory.AddFont( + this, + asset, + fontConfig with + { + FontNo = 0, + }); + } + } + + /// + public ImFontPtr AddFontAwesomeIconFont(in SafeFontConfig fontConfig) => this.AddDalamudAssetFont( + DalamudAsset.FontAwesomeFreeSolid, + fontConfig with + { + GlyphRanges = new ushort[] { FontAwesomeIconMin, FontAwesomeIconMax, 0 }, + }); + + /// + public ImFontPtr AddGameSymbol(in SafeFontConfig fontConfig) => + this.AddDalamudAssetFont( + DalamudAsset.LodestoneGameSymbol, + fontConfig with + { + GlyphRanges = new ushort[] + { + GamePrebakedFontHandle.SeIconCharMin, + GamePrebakedFontHandle.SeIconCharMax, + 0, + }, + }); + + /// + public ImFontPtr AddGameGlyphs(GameFontStyle gameFontStyle, ushort[]? glyphRanges, ImFontPtr mergeFont) => + this.gameFontHandleSubstance.AttachGameGlyphs(this, mergeFont, gameFontStyle, glyphRanges); + + /// + public void AttachExtraGlyphsForDalamudLanguage(in SafeFontConfig fontConfig) + { + var dalamudConfiguration = Service.Get(); + if (dalamudConfiguration.EffectiveLanguage == "ko" + || Service.GetNullable()?.EncounteredHangul is true) + { + this.AddDalamudAssetFont( + DalamudAsset.NotoSansKrRegular, + fontConfig with + { + GlyphRanges = ImGuiHelpers.CreateImGuiRangesFrom( + UnicodeRanges.HangulJamo, + UnicodeRanges.HangulCompatibilityJamo, + UnicodeRanges.HangulSyllables, + UnicodeRanges.HangulJamoExtendedA, + UnicodeRanges.HangulJamoExtendedB), + }); + } + + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var fontPathChs = Path.Combine(windowsDir, "Fonts", "msyh.ttc"); + if (!File.Exists(fontPathChs)) + fontPathChs = null; + + var fontPathCht = Path.Combine(windowsDir, "Fonts", "msjh.ttc"); + if (!File.Exists(fontPathCht)) + fontPathCht = null; + + if (fontPathCht != null && Service.Get().EffectiveLanguage == "tw") + { + this.AddFontFromFile(fontPathCht, fontConfig with + { + GlyphRanges = ImGuiHelpers.CreateImGuiRangesFrom( + UnicodeRanges.CjkUnifiedIdeographs, + UnicodeRanges.CjkUnifiedIdeographsExtensionA), + }); + } + else if (fontPathChs != null && (Service.Get().EffectiveLanguage == "zh" + || Service.GetNullable()?.EncounteredHan is true)) + { + this.AddFontFromFile(fontPathChs, fontConfig with + { + GlyphRanges = ImGuiHelpers.CreateImGuiRangesFrom( + UnicodeRanges.CjkUnifiedIdeographs, + UnicodeRanges.CjkUnifiedIdeographsExtensionA), + }); + } + } + + public void PreBuildSubstances() + { + foreach (var substance in this.data.Substances) + substance.OnPreBuild(this); + foreach (var substance in this.data.Substances) + substance.OnPreBuildCleanup(this); + } + + public unsafe void PreBuild() + { + var configData = this.data.ConfigData; + foreach (ref var config in configData.DataSpan) + { + if (this.GlobalScaleExclusions.Contains(new(config.DstFont))) + continue; + + config.SizePixels *= this.Scale; + + config.GlyphMaxAdvanceX *= this.Scale; + if (float.IsInfinity(config.GlyphMaxAdvanceX)) + config.GlyphMaxAdvanceX = config.GlyphMaxAdvanceX > 0 ? float.MaxValue : -float.MaxValue; + + config.GlyphMinAdvanceX *= this.Scale; + if (float.IsInfinity(config.GlyphMinAdvanceX)) + config.GlyphMinAdvanceX = config.GlyphMinAdvanceX > 0 ? float.MaxValue : -float.MaxValue; + + config.GlyphOffset *= this.Scale; + } + } + + public void DoBuild() + { + // ImGui will call AddFontDefault() on Build() call. + // AddFontDefault() will reliably crash, when invoked multithreaded. + // We add a dummy font to prevent that. + if (this.data.ConfigData.Length == 0) + { + this.AddDalamudAssetFont( + DalamudAsset.NotoSansJpMedium, + new() { GlyphRanges = new ushort[] { ' ', ' ', '\0' }, SizePx = 1 }); + } + + if (!this.NewImAtlas.Build()) + throw new InvalidOperationException("ImFontAtlas.Build failed"); + + this.BuildStep = FontAtlasBuildStep.PostBuild; + } + + public unsafe void PostBuild() + { + var scale = this.Scale; + foreach (ref var font in this.Fonts.DataSpan) + { + if (!this.GlobalScaleExclusions.Contains(font)) + font.AdjustGlyphMetrics(1 / scale, 1 / scale); + + foreach (var c in FallbackCodepoints) + { + var g = font.FindGlyphNoFallback(c); + if (g.NativePtr == null) + continue; + + font.UpdateFallbackChar(c); + break; + } + + foreach (var c in EllipsisCodepoints) + { + var g = font.FindGlyphNoFallback(c); + if (g.NativePtr == null) + continue; + + font.EllipsisChar = c; + break; + } + } + } + + public void PostBuildSubstances() + { + foreach (var substance in this.data.Substances) + substance.OnPostBuild(this); + } + + public unsafe void UploadTextures() + { + var buf = Array.Empty(); + try + { + var use4 = this.factory.InterfaceManager.SupportsDxgiFormat(Format.B4G4R4A4_UNorm); + var bpp = use4 ? 2 : 4; + var width = this.NewImAtlas.TexWidth; + var height = this.NewImAtlas.TexHeight; + foreach (ref var texture in this.data.ImTextures.DataSpan) + { + if (texture.TexID != 0) + { + // Nothing to do + } + else if (texture.TexPixelsRGBA32 is not null) + { + var wrap = this.factory.InterfaceManager.LoadImageFromDxgiFormat( + new(texture.TexPixelsRGBA32, width * height * 4), + width * 4, + width, + height, + use4 ? Format.B4G4R4A4_UNorm : Format.R8G8B8A8_UNorm); + this.data.AddExistingTexture(wrap); + texture.TexID = wrap.ImGuiHandle; + } + else if (texture.TexPixelsAlpha8 is not null) + { + var numPixels = width * height; + if (buf.Length < numPixels * bpp) + { + ArrayPool.Shared.Return(buf); + buf = ArrayPool.Shared.Rent(numPixels * bpp); + } + + fixed (void* pBuf = buf) + { + var sourcePtr = texture.TexPixelsAlpha8; + if (use4) + { + var target = (ushort*)pBuf; + while (numPixels-- > 0) + { + *target = (ushort)((*sourcePtr << 8) | 0x0FFF); + target++; + sourcePtr++; + } + } + else + { + var target = (uint*)pBuf; + while (numPixels-- > 0) + { + *target = (uint)((*sourcePtr << 24) | 0x00FFFFFF); + target++; + sourcePtr++; + } + } + } + + var wrap = this.factory.InterfaceManager.LoadImageFromDxgiFormat( + buf, + width * bpp, + width, + height, + use4 ? Format.B4G4R4A4_UNorm : Format.B8G8R8A8_UNorm); + this.data.AddExistingTexture(wrap); + texture.TexID = wrap.ImGuiHandle; + continue; + } + else + { + Log.Warning( + "[{name}]: TexID, TexPixelsRGBA32, and TexPixelsAlpha8 are all null", + this.data.Owner?.Name ?? "(error)"); + } + + if (texture.TexPixelsRGBA32 is not null) + ImGuiNative.igMemFree(texture.TexPixelsRGBA32); + if (texture.TexPixelsAlpha8 is not null) + ImGuiNative.igMemFree(texture.TexPixelsAlpha8); + texture.TexPixelsRGBA32 = null; + texture.TexPixelsAlpha8 = null; + } + } + finally + { + ArrayPool.Shared.Return(buf); + } + } + + /// + public unsafe void CopyGlyphsAcrossFonts( + ImFontPtr source, + ImFontPtr target, + bool missingOnly, + bool rebuildLookupTable = true, + char rangeLow = ' ', + char rangeHigh = '\uFFFE') + { + var sourceFound = false; + var targetFound = false; + foreach (var f in this.Fonts) + { + sourceFound |= f.NativePtr == source.NativePtr; + targetFound |= f.NativePtr == target.NativePtr; + } + + if (sourceFound && targetFound) + { + ImGuiHelpers.CopyGlyphsAcrossFonts( + source, + target, + missingOnly, + false, + rangeLow, + rangeHigh); + if (rebuildLookupTable) + this.BuildLookupTable(target); + } + } + + /// + public unsafe void BuildLookupTable(ImFontPtr font) + { + // Need to clear previous Fallback pointers before BuildLookupTable, or it may crash + font.NativePtr->FallbackGlyph = null; + font.NativePtr->FallbackHotData = null; + font.BuildLookupTable(); + + // Need to fix our custom ImGui, so that imgui_widgets.cpp:3656 stops thinking + // Codepoint < FallbackHotData.size always means that it's not fallback char. + // Otherwise, having a fallback character in ImGui.InputText gets strange. + var indexedHotData = font.IndexedHotDataWrapped(); + var indexLookup = font.IndexLookupWrapped(); + ref var fallbackHotData = ref *(ImGuiHelpers.ImFontGlyphHotDataReal*)font.NativePtr->FallbackHotData; + for (var codepoint = 0; codepoint < indexedHotData.Length; codepoint++) + { + if (indexLookup[codepoint] == ushort.MaxValue) + { + indexedHotData[codepoint].AdvanceX = fallbackHotData.AdvanceX; + indexedHotData[codepoint].OccupiedWidth = fallbackHotData.OccupiedWidth; + } + } + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs new file mode 100644 index 000000000..4d636b8cf --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs @@ -0,0 +1,796 @@ +// #define VeryVerboseLog + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Disposables; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; +using Dalamud.Utility; + +using ImGuiNET; + +using JetBrains.Annotations; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Standalone font atlas. +/// +internal sealed partial class FontAtlasFactory +{ + /// + /// Fallback codepoints for ImFont. + /// + public const string FallbackCodepoints = "\u3013\uFFFD?-"; + + /// + /// Ellipsis codepoints for ImFont. + /// + public const string EllipsisCodepoints = "\u2026\u0085"; + + /// + /// If set, disables concurrent font build operation. + /// + private static readonly object? NoConcurrentBuildOperationLock = null; // new(); + + private static readonly ModuleLog Log = new(nameof(FontAtlasFactory)); + + private static readonly Task EmptyTask = Task.FromResult(default(FontAtlasBuiltData)); + + private class FontAtlasBuiltData : IRefCountable + { + private readonly List wraps; + private readonly List substances; + + private int refCount; + + public unsafe FontAtlasBuiltData(DalamudFontAtlas owner, float scale) + { + this.Owner = owner; + this.Scale = scale; + this.Garbage = new(); + this.refCount = 1; + + try + { + var substancesList = this.substances = new(); + this.Garbage.Add(() => substancesList.Clear()); + + var wrapsCopy = this.wraps = new(); + this.Garbage.Add(() => wrapsCopy.Clear()); + + var atlasPtr = ImGuiNative.ImFontAtlas_ImFontAtlas(); + this.Atlas = atlasPtr; + if (this.Atlas.NativePtr is null) + throw new OutOfMemoryException($"Failed to allocate a new {nameof(ImFontAtlas)}."); + + this.Garbage.Add(() => ImGuiNative.ImFontAtlas_destroy(atlasPtr)); + this.IsBuildInProgress = true; + } + catch + { + this.Garbage.Dispose(); + throw; + } + } + + public DalamudFontAtlas? Owner { get; } + + public ImFontAtlasPtr Atlas { get; } + + public float Scale { get; } + + public bool IsBuildInProgress { get; set; } + + public DisposeSafety.ScopedFinalizer Garbage { get; } + + public ImVectorWrapper Fonts => this.Atlas.FontsWrapped(); + + public ImVectorWrapper ConfigData => this.Atlas.ConfigDataWrapped(); + + public ImVectorWrapper ImTextures => this.Atlas.TexturesWrapped(); + + public IReadOnlyList Wraps => this.wraps; + + public IReadOnlyList Substances => this.substances; + + public void InitialAddSubstance(IFontHandleSubstance substance) => + this.substances.Add(this.Garbage.Add(substance)); + + public void AddExistingTexture(IDalamudTextureWrap wrap) + { + if (this.wraps is null) + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)); + + this.wraps.Add(this.Garbage.Add(wrap)); + } + + public int AddNewTexture(IDalamudTextureWrap wrap, bool disposeOnError) + { + if (this.wraps is null) + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)); + + var handle = wrap.ImGuiHandle; + var index = this.ImTextures.IndexOf(x => x.TexID == handle); + if (index == -1) + { + try + { + this.wraps.EnsureCapacity(this.wraps.Count + 1); + this.ImTextures.EnsureCapacityExponential(this.ImTextures.Length + 1); + + index = this.ImTextures.Length; + this.wraps.Add(this.Garbage.Add(wrap)); + this.ImTextures.Add(new() { TexID = handle }); + } + catch (Exception e) + { + if (disposeOnError) + wrap.Dispose(); + + if (this.wraps.Count != this.ImTextures.Length) + { + Log.Error( + e, + "{name} failed, and {wraps} and {imtextures} have different number of items", + nameof(this.AddNewTexture), + nameof(this.Wraps), + nameof(this.ImTextures)); + + if (this.wraps.Count > 0 && this.wraps[^1] == wrap) + this.wraps.RemoveAt(this.wraps.Count - 1); + if (this.ImTextures.Length > 0 && this.ImTextures[^1].TexID == handle) + this.ImTextures.RemoveAt(this.ImTextures.Length - 1); + + if (this.wraps.Count != this.ImTextures.Length) + Log.Fatal("^ Failed to undo due to an internal inconsistency; embrace for a crash"); + } + + throw; + } + } + + return index; + } + + public int AddRef() => IRefCountable.AlterRefCount(1, ref this.refCount, out var newRefCount) switch + { + IRefCountable.RefCountResult.StillAlive => newRefCount, + IRefCountable.RefCountResult.AlreadyDisposed => + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)), + IRefCountable.RefCountResult.FinalRelease => throw new InvalidOperationException(), + _ => throw new InvalidOperationException(), + }; + + public int Release() + { + switch (IRefCountable.AlterRefCount(-1, ref this.refCount, out var newRefCount)) + { + case IRefCountable.RefCountResult.StillAlive: + return newRefCount; + + case IRefCountable.RefCountResult.FinalRelease: +#if VeryVerboseLog + Log.Verbose("[{name}] 0x{ptr:X}: Disposing", this.Owner?.Name ?? "", (nint)this.Atlas.NativePtr); +#endif + + if (this.IsBuildInProgress) + { + unsafe + { + Log.Error( + "[{name}] 0x{ptr:X}: Trying to dispose while build is in progress; disposing later.\n" + + "Stack:\n{trace}", + this.Owner?.Name ?? "", + (nint)this.Atlas.NativePtr, + new StackTrace()); + } + + Task.Run( + async () => + { + while (this.IsBuildInProgress) + await Task.Delay(100); + this.Garbage.Dispose(); + }); + } + else + { + this.Garbage.Dispose(); + } + + return newRefCount; + + case IRefCountable.RefCountResult.AlreadyDisposed: + throw new ObjectDisposedException(nameof(FontAtlasBuiltData)); + + default: + throw new InvalidOperationException(); + } + } + + public BuildToolkit CreateToolkit(FontAtlasFactory factory, bool isAsync) + { + var axisSubstance = this.Substances.OfType().Single(); + return new(factory, this, axisSubstance, isAsync) { BuildStep = FontAtlasBuildStep.PreBuild }; + } + } + + private class DalamudFontAtlas : IFontAtlas, DisposeSafety.IDisposeCallback + { + private readonly DisposeSafety.ScopedFinalizer disposables = new(); + private readonly FontAtlasFactory factory; + private readonly DelegateFontHandle.HandleManager delegateFontHandleManager; + private readonly GamePrebakedFontHandle.HandleManager gameFontHandleManager; + private readonly IFontHandleManager[] fontHandleManagers; + + private readonly object syncRoot = new(); + + private Task buildTask = EmptyTask; + private FontAtlasBuiltData? builtData; + + private int buildSuppressionCounter; + private bool buildSuppressionSuppressed; + + private int buildIndex; + private bool buildQueued; + private bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The factory. + /// Name of atlas, for debugging and logging purposes. + /// Specify how to auto rebuild. + /// Whether the fonts in the atlas are under the effect of global scale. + public DalamudFontAtlas( + FontAtlasFactory factory, + string atlasName, + FontAtlasAutoRebuildMode autoRebuildMode, + bool isGlobalScaled) + { + this.IsGlobalScaled = isGlobalScaled; + try + { + this.factory = factory; + this.AutoRebuildMode = autoRebuildMode; + this.Name = atlasName; + + this.factory.InterfaceManager.AfterBuildFonts += this.OnRebuildRecommend; + this.disposables.Add(() => this.factory.InterfaceManager.AfterBuildFonts -= this.OnRebuildRecommend); + + this.fontHandleManagers = new IFontHandleManager[] + { + this.delegateFontHandleManager = this.disposables.Add( + new DelegateFontHandle.HandleManager(atlasName)), + this.gameFontHandleManager = this.disposables.Add( + new GamePrebakedFontHandle.HandleManager(atlasName, factory)), + }; + foreach (var fhm in this.fontHandleManagers) + fhm.RebuildRecommend += this.OnRebuildRecommend; + } + catch + { + this.disposables.Dispose(); + throw; + } + + this.factory.SceneTask.ContinueWith( + r => + { + lock (this.syncRoot) + { + if (this.disposed) + return; + + r.Result.OnNewRenderFrame += this.ImGuiSceneOnNewRenderFrame; + this.disposables.Add(() => r.Result.OnNewRenderFrame -= this.ImGuiSceneOnNewRenderFrame); + } + + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.OnNewFrame) + this.BuildFontsOnNextFrame(); + }); + } + + /// + /// Finalizes an instance of the class. + /// + ~DalamudFontAtlas() + { + lock (this.syncRoot) + { + this.buildTask.ToDisposableIgnoreExceptions().Dispose(); + this.builtData?.Release(); + this.builtData = null; + } + } + + /// + public event FontAtlasBuildStepDelegate? BuildStepChange; + + /// + public event Action? RebuildRecommend; + + /// + public event Action? BeforeDispose; + + /// + public event Action? AfterDispose; + + /// + public string Name { get; } + + /// + public FontAtlasAutoRebuildMode AutoRebuildMode { get; } + + /// + public ImFontAtlasPtr ImAtlas + { + get + { + lock (this.syncRoot) + return this.builtData?.Atlas ?? default; + } + } + + /// + public Task BuildTask => this.buildTask; + + /// + public bool HasBuiltAtlas => !(this.builtData?.Atlas.IsNull() ?? true); + + /// + public bool IsGlobalScaled { get; } + + /// + public void Dispose() + { + if (this.disposed) + return; + + this.BeforeDispose?.InvokeSafely(this); + + try + { + lock (this.syncRoot) + { + this.disposed = true; + this.buildTask.ToDisposableIgnoreExceptions().Dispose(); + this.buildTask = EmptyTask; + this.disposables.Add(this.builtData); + this.builtData = default; + this.disposables.Dispose(); + } + + try + { + this.AfterDispose?.Invoke(this, null); + } + catch + { + // ignore + } + } + catch (Exception e) + { + try + { + this.AfterDispose?.Invoke(this, e); + } + catch + { + // ignore + } + } + + GC.SuppressFinalize(this); + } + + /// + public IDisposable SuppressAutoRebuild() + { + this.buildSuppressionCounter++; + return Disposable.Create( + () => + { + this.buildSuppressionCounter--; + if (this.buildSuppressionSuppressed) + this.OnRebuildRecommend(); + }); + } + + /// + public IFontHandle NewGameFontHandle(GameFontStyle style) => this.gameFontHandleManager.NewFontHandle(style); + + /// + public IFontHandle NewDelegateFontHandle(FontAtlasBuildStepDelegate buildStepDelegate) => + this.delegateFontHandleManager.NewFontHandle(buildStepDelegate); + + /// + public void BuildFontsOnNextFrame() + { + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.Async) + { + throw new InvalidOperationException( + $"{nameof(this.BuildFontsOnNextFrame)} cannot be used when " + + $"{nameof(this.AutoRebuildMode)} is set to " + + $"{nameof(FontAtlasAutoRebuildMode.Async)}."); + } + + if (!this.buildTask.IsCompleted || this.buildQueued) + return; + +#if VeryVerboseLog + Log.Verbose("[{name}] Queueing from {source}.", this.Name, nameof(this.BuildFontsOnNextFrame)); +#endif + + this.buildQueued = true; + } + + /// + public void BuildFontsImmediately() + { +#if VeryVerboseLog + Log.Verbose("[{name}] Called: {source}.", this.Name, nameof(this.BuildFontsImmediately)); +#endif + + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.Async) + { + throw new InvalidOperationException( + $"{nameof(this.BuildFontsImmediately)} cannot be used when " + + $"{nameof(this.AutoRebuildMode)} is set to " + + $"{nameof(FontAtlasAutoRebuildMode.Async)}."); + } + + var tcs = new TaskCompletionSource(); + try + { + var rebuildIndex = Interlocked.Increment(ref this.buildIndex); + lock (this.syncRoot) + { + if (!this.buildTask.IsCompleted) + throw new InvalidOperationException("Font rebuild is already in progress."); + + this.buildTask = tcs.Task; + } + +#if VeryVerboseLog + Log.Verbose("[{name}] Building from {source}.", this.Name, nameof(this.BuildFontsImmediately)); +#endif + + var scale = this.IsGlobalScaled ? ImGuiHelpers.GlobalScaleSafe : 1f; + var r = this.RebuildFontsPrivate(false, scale); + r.Wait(); + if (r.IsCompletedSuccessfully) + { + this.PromoteBuiltData(rebuildIndex, r.Result, nameof(this.BuildFontsImmediately)); + tcs.SetResult(r.Result); + } + else if ((r.Exception?.InnerException ?? r.Exception) is { } taskException) + { + ExceptionDispatchInfo.Capture(taskException).Throw(); + } + else + { + throw new OperationCanceledException(); + } + } + catch (Exception e) + { + tcs.SetException(e); + Log.Error(e, "[{name}] Failed to build fonts.", this.Name); + throw; + } + } + + /// + public Task BuildFontsAsync() + { +#if VeryVerboseLog + Log.Verbose("[{name}] Called: {source}.", this.Name, nameof(this.BuildFontsAsync)); +#endif + + if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.OnNewFrame) + { + throw new InvalidOperationException( + $"{nameof(this.BuildFontsAsync)} cannot be used when " + + $"{nameof(this.AutoRebuildMode)} is set to " + + $"{nameof(FontAtlasAutoRebuildMode.OnNewFrame)}."); + } + + lock (this.syncRoot) + { + var scale = this.IsGlobalScaled ? ImGuiHelpers.GlobalScaleSafe : 1f; + var rebuildIndex = Interlocked.Increment(ref this.buildIndex); + return this.buildTask = this.buildTask.ContinueWith(BuildInner).Unwrap(); + + async Task BuildInner(Task unused) + { + Log.Verbose("[{name}] Building from {source}.", this.Name, nameof(this.BuildFontsAsync)); + lock (this.syncRoot) + { + if (this.buildIndex != rebuildIndex) + return null; + } + + var res = await this.RebuildFontsPrivate(true, scale); + if (res.Atlas.IsNull()) + return res; + + this.PromoteBuiltData(rebuildIndex, res, nameof(this.BuildFontsAsync)); + + return res; + } + } + } + + private void PromoteBuiltData(int rebuildIndex, FontAtlasBuiltData data, [UsedImplicitly] string source) + { + // Capture the locks inside the lock block, so that the fonts are guaranteed to be the ones just built. + var fontsAndLocks = new List<(FontHandle FontHandle, ILockedImFont Lock)>(); + using var garbage = new DisposeSafety.ScopedFinalizer(); + + lock (this.syncRoot) + { + if (this.buildIndex != rebuildIndex) + { + data.ExplicitDisposeIgnoreExceptions(); + return; + } + + var prevBuiltData = this.builtData; + this.builtData = data; + prevBuiltData.ExplicitDisposeIgnoreExceptions(); + + this.buildTask = EmptyTask; + fontsAndLocks.EnsureCapacity(data.Substances.Sum(x => x.RelevantHandles.Count)); + foreach (var substance in data.Substances) + { + substance.Manager.Substance = substance; + foreach (var fontHandle in substance.RelevantHandles) + { + substance.DataRoot.AddRef(); + var locked = new LockedImFont( + substance.GetFontPtr(fontHandle), + substance.DataRoot); + fontsAndLocks.Add((fontHandle, garbage.Add(locked))); + } + } + } + + foreach (var (fontHandle, lockedFont) in fontsAndLocks) + fontHandle.InvokeImFontChanged(lockedFont); + +#if VeryVerboseLog + Log.Verbose("[{name}] Built from {source}.", this.Name, source); +#endif + } + + private void ImGuiSceneOnNewRenderFrame() + { + if (!this.buildQueued) + return; + + try + { + if (this.AutoRebuildMode != FontAtlasAutoRebuildMode.Async) + this.BuildFontsImmediately(); + } + finally + { + this.buildQueued = false; + } + } + + private Task RebuildFontsPrivate(bool isAsync, float scale) + { + if (NoConcurrentBuildOperationLock is null) + return this.RebuildFontsPrivateReal(isAsync, scale); + lock (NoConcurrentBuildOperationLock) + return this.RebuildFontsPrivateReal(isAsync, scale); + } + + private async Task RebuildFontsPrivateReal(bool isAsync, float scale) + { + lock (this.syncRoot) + { + // this lock ensures that this.buildTask is properly set. + } + + var sw = new Stopwatch(); + sw.Start(); + + FontAtlasBuiltData? res = null; + nint atlasPtr = 0; + BuildToolkit? toolkit = null; + try + { + res = new(this, scale); + foreach (var fhm in this.fontHandleManagers) + res.InitialAddSubstance(fhm.NewSubstance(res)); + unsafe + { + atlasPtr = (nint)res.Atlas.NativePtr; + } + + Log.Verbose( + "[{name}:{functionname}] 0x{ptr:X}: PreBuild (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + + toolkit = res.CreateToolkit(this.factory, isAsync); + this.BuildStepChange?.Invoke(toolkit); + toolkit.PreBuildSubstances(); + toolkit.PreBuild(); + + // Prevent NewImAtlas.ConfigData[].DstFont pointing to a font not owned by the new atlas, + // by making it add a font with default configuration first instead. + if (!ValidateMergeFontReferences(default)) + { + Log.Warning( + "[{name}:{functionname}] 0x{ptr:X}: refering to fonts outside the new atlas; " + + "adding a default font, and using that as the merge target.", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr); + + res.IsBuildInProgress = false; + toolkit.Dispose(); + res.Release(); + + res = new(this, scale); + foreach (var fhm in this.fontHandleManagers) + res.InitialAddSubstance(fhm.NewSubstance(res)); + unsafe + { + atlasPtr = (nint)res.Atlas.NativePtr; + } + + toolkit = res.CreateToolkit(this.factory, isAsync); + + // PreBuildSubstances deals with toolkit.Add... function family. Do this first. + var defaultFont = toolkit.AddDalamudDefaultFont(InterfaceManager.DefaultFontSizePx, null); + + this.BuildStepChange?.Invoke(toolkit); + toolkit.PreBuildSubstances(); + toolkit.PreBuild(); + + _ = ValidateMergeFontReferences(defaultFont); + } + +#if VeryVerboseLog + Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: Build (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds); +#endif + + toolkit.DoBuild(); + +#if VeryVerboseLog + Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: PostBuild (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds); +#endif + + toolkit.PostBuild(); + toolkit.PostBuildSubstances(); + this.BuildStepChange?.Invoke(toolkit); + + foreach (var font in toolkit.Fonts) + toolkit.BuildLookupTable(font); + + if (this.factory.SceneTask is { IsCompleted: false } sceneTask) + { + Log.Verbose( + "[{name}:{functionname}] 0x{ptr:X}: await SceneTask (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + await sceneTask.ConfigureAwait(!isAsync); + } + +#if VeryVerboseLog + Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: UploadTextures (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds); +#endif + toolkit.UploadTextures(); + + Log.Verbose( + "[{name}:{functionname}] 0x{ptr:X}: Complete (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + + res.IsBuildInProgress = false; + return res; + } + catch (Exception e) + { + Log.Error( + e, + "[{name}:{functionname}] 0x{ptr:X}: Failed (at {sw}ms)", + this.Name, + nameof(this.RebuildFontsPrivateReal), + atlasPtr, + sw.ElapsedMilliseconds); + if (res is not null) + { + res.IsBuildInProgress = false; + res.Release(); + } + + throw; + } + finally + { + // RS is being dumb + // ReSharper disable once ConstantConditionalAccessQualifier + toolkit?.Dispose(); + this.buildQueued = false; + } + + unsafe bool ValidateMergeFontReferences(ImFontPtr replacementDstFont) + { + var correct = true; + foreach (ref var configData in toolkit.NewImAtlas.ConfigDataWrapped().DataSpan) + { + var found = false; + foreach (ref var font in toolkit.Fonts.DataSpan) + { + if (configData.DstFont == font) + { + found = true; + break; + } + } + + if (!found) + { + correct = false; + configData.DstFont = replacementDstFont; + } + } + + return correct; + } + } + + private void OnRebuildRecommend() + { + if (this.disposed) + return; + + if (this.buildSuppressionCounter > 0) + { + this.buildSuppressionSuppressed = true; + return; + } + + this.buildSuppressionSuppressed = false; + this.factory.Framework.RunOnFrameworkThread( + () => + { + this.RebuildRecommend?.InvokeSafely(); + + switch (this.AutoRebuildMode) + { + case FontAtlasAutoRebuildMode.Async: + _ = this.BuildFontsAsync(); + break; + case FontAtlasAutoRebuildMode.OnNewFrame: + this.BuildFontsOnNextFrame(); + break; + case FontAtlasAutoRebuildMode.Disable: + default: + break; + } + }); + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.cs new file mode 100644 index 000000000..358ccd845 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.cs @@ -0,0 +1,368 @@ +using System.Buffers; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Configuration.Internal; +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Storage.Assets; +using Dalamud.Utility; + +using ImGuiNET; + +using ImGuiScene; + +using Lumina.Data.Files; + +using SharpDX; +using SharpDX.Direct3D11; +using SharpDX.DXGI; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Factory for the implementation of . +/// +[ServiceManager.BlockingEarlyLoadedService] +internal sealed partial class FontAtlasFactory + : IServiceType, GamePrebakedFontHandle.IGameFontTextureProvider, IDisposable +{ + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private readonly CancellationTokenSource cancellationTokenSource = new(); + private readonly IReadOnlyDictionary> fdtFiles; + private readonly IReadOnlyDictionary[]>> texFiles; + private readonly IReadOnlyDictionary> prebakedTextureWraps; + private readonly Task defaultGlyphRanges; + private readonly DalamudAssetManager dalamudAssetManager; + + [ServiceManager.ServiceConstructor] + private FontAtlasFactory( + DataManager dataManager, + Framework framework, + InterfaceManager interfaceManager, + DalamudAssetManager dalamudAssetManager) + { + this.Framework = framework; + this.InterfaceManager = interfaceManager; + this.dalamudAssetManager = dalamudAssetManager; + this.SceneTask = Service + .GetAsync() + .ContinueWith(r => r.Result.Manager.Scene); + + var gffasInfo = Enum.GetValues() + .Select( + x => + ( + Font: x, + Attr: x.GetAttribute())) + .Where(x => x.Attr is not null) + .ToArray(); + var texPaths = gffasInfo.Select(x => x.Attr.TexPathFormat).Distinct().ToArray(); + + this.fdtFiles = gffasInfo.ToImmutableDictionary( + x => x.Font, + x => Task.Run(() => dataManager.GetFile(x.Attr.Path)!.Data)); + var channelCountsTask = texPaths.ToImmutableDictionary( + x => x, + x => Task.WhenAll( + gffasInfo.Where(y => y.Attr.TexPathFormat == x) + .Select(y => this.fdtFiles[y.Font])) + .ContinueWith( + files => 1 + files.Result.Max( + file => + { + unsafe + { + using var pin = file.AsMemory().Pin(); + var fdt = new FdtFileView(pin.Pointer, file.Length); + return fdt.MaxTextureIndex; + } + }))); + this.prebakedTextureWraps = channelCountsTask.ToImmutableDictionary( + x => x.Key, + x => x.Value.ContinueWith(y => new IDalamudTextureWrap?[y.Result])); + this.texFiles = channelCountsTask.ToImmutableDictionary( + x => x.Key, + x => x.Value.ContinueWith( + y => Enumerable + .Range(1, 1 + ((y.Result - 1) / 4)) + .Select(z => Task.Run(() => dataManager.GetFile(string.Format(x.Key, z))!)) + .ToArray())); + this.defaultGlyphRanges = + this.fdtFiles[GameFontFamilyAndSize.Axis12] + .ContinueWith( + file => + { + unsafe + { + using var pin = file.Result.AsMemory().Pin(); + var fdt = new FdtFileView(pin.Pointer, file.Result.Length); + return fdt.ToGlyphRanges(); + } + }); + } + + /// + /// Gets or sets a value indicating whether to override configuration for UseAxis. + /// + public bool? UseAxisOverride { get; set; } = null; + + /// + /// Gets a value indicating whether to use AXIS fonts. + /// + public bool UseAxis => this.UseAxisOverride ?? Service.Get().UseAxisFontsFromGame; + + /// + /// Gets the service instance of . + /// + public Framework Framework { get; } + + /// + /// Gets the service instance of .
+ /// may not yet be available. + ///
+ public InterfaceManager InterfaceManager { get; } + + /// + /// Gets the async task for inside . + /// + public Task SceneTask { get; } + + /// + /// Gets the default glyph ranges (glyph ranges of ). + /// + public ushort[] DefaultGlyphRanges => ExtractResult(this.defaultGlyphRanges); + + /// + /// Gets a value indicating whether game symbol font file is available. + /// + public bool HasGameSymbolsFontFile => + this.dalamudAssetManager.IsStreamImmediatelyAvailable(DalamudAsset.LodestoneGameSymbol); + + /// + public void Dispose() + { + this.cancellationTokenSource.Cancel(); + this.scopedFinalizer.Dispose(); + this.cancellationTokenSource.Dispose(); + } + + /// + /// Creates a new instance of a class that implements the interface. + /// + /// Name of atlas, for debugging and logging purposes. + /// Specify how to auto rebuild. + /// Whether the fonts in the atlas is global scaled. + /// The new font atlas. + public IFontAtlas CreateFontAtlas( + string atlasName, + FontAtlasAutoRebuildMode autoRebuildMode, + bool isGlobalScaled = true) => + new DalamudFontAtlas(this, atlasName, autoRebuildMode, isGlobalScaled); + + /// + /// Adds the font from Dalamud Assets. + /// + /// The toolkitPostBuild. + /// The font. + /// The font config. + /// The address and size. + public ImFontPtr AddFont( + IFontAtlasBuildToolkitPreBuild toolkitPreBuild, + DalamudAsset asset, + in SafeFontConfig fontConfig) => + toolkitPreBuild.AddFontFromStream( + this.dalamudAssetManager.CreateStream(asset), + fontConfig, + false, + $"Asset({asset})"); + + /// + /// Gets the for the . + /// + /// The font family and size. + /// The . + public FdtReader GetFdtReader(GameFontFamilyAndSize gffas) => new(ExtractResult(this.fdtFiles[gffas])); + + /// + public unsafe MemoryHandle CreateFdtFileView(GameFontFamilyAndSize gffas, out FdtFileView fdtFileView) + { + var arr = ExtractResult(this.fdtFiles[gffas]); + var handle = arr.AsMemory().Pin(); + try + { + fdtFileView = new(handle.Pointer, arr.Length); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + /// + public int GetFontTextureCount(string texPathFormat) => + ExtractResult(this.prebakedTextureWraps[texPathFormat]).Length; + + /// + public TexFile GetTexFile(string texPathFormat, int index) => + ExtractResult(ExtractResult(this.texFiles[texPathFormat])[index]); + + /// + public IDalamudTextureWrap NewFontTextureRef(string texPathFormat, int textureIndex) + { + lock (this.prebakedTextureWraps[texPathFormat]) + { + var wraps = ExtractResult(this.prebakedTextureWraps[texPathFormat]); + var fileIndex = textureIndex / 4; + var channelIndex = FdtReader.FontTableEntry.TextureChannelOrder[textureIndex % 4]; + wraps[textureIndex] ??= this.GetChannelTexture(texPathFormat, fileIndex, channelIndex); + return CloneTextureWrap(wraps[textureIndex]); + } + } + + private static T ExtractResult(Task t) => t.IsCompleted ? t.Result : t.GetAwaiter().GetResult(); + + private static unsafe void ExtractChannelFromB8G8R8A8( + Span target, + ReadOnlySpan source, + int channelIndex, + bool targetIsB4G4R4A4) + { + var numPixels = Math.Min(source.Length / 4, target.Length / (targetIsB4G4R4A4 ? 2 : 4)); + + fixed (byte* sourcePtrImmutable = source) + { + var rptr = sourcePtrImmutable + channelIndex; + fixed (void* targetPtr = target) + { + if (targetIsB4G4R4A4) + { + var wptr = (ushort*)targetPtr; + while (numPixels-- > 0) + { + *wptr = (ushort)((*rptr << 8) | 0x0FFF); + wptr++; + rptr += 4; + } + } + else + { + var wptr = (uint*)targetPtr; + while (numPixels-- > 0) + { + *wptr = (uint)((*rptr << 24) | 0x00FFFFFF); + wptr++; + rptr += 4; + } + } + } + } + } + + /// + /// Clones a texture wrap, by getting a new reference to the underlying and the + /// texture behind. + /// + /// The to clone from. + /// The cloned . + private static IDalamudTextureWrap CloneTextureWrap(IDalamudTextureWrap wrap) + { + var srv = CppObject.FromPointer(wrap.ImGuiHandle); + using var res = srv.Resource; + using var tex2D = res.QueryInterface(); + var description = tex2D.Description; + return new DalamudTextureWrap( + new D3DTextureWrap( + srv.QueryInterface(), + description.Width, + description.Height)); + } + + private static unsafe void ExtractChannelFromB4G4R4A4( + Span target, + ReadOnlySpan source, + int channelIndex, + bool targetIsB4G4R4A4) + { + var numPixels = Math.Min(source.Length / 2, target.Length / (targetIsB4G4R4A4 ? 2 : 4)); + fixed (byte* sourcePtrImmutable = source) + { + var rptr = sourcePtrImmutable + (channelIndex / 2); + var rshift = (channelIndex & 1) == 0 ? 0 : 4; + fixed (void* targetPtr = target) + { + if (targetIsB4G4R4A4) + { + var wptr = (ushort*)targetPtr; + while (numPixels-- > 0) + { + *wptr = (ushort)(((*rptr >> rshift) << 12) | 0x0FFF); + wptr++; + rptr += 2; + } + } + else + { + var wptr = (uint*)targetPtr; + while (numPixels-- > 0) + { + var v = (*rptr >> rshift) & 0xF; + v |= v << 4; + *wptr = (uint)((v << 24) | 0x00FFFFFF); + wptr++; + rptr += 4; + } + } + } + } + } + + private IDalamudTextureWrap GetChannelTexture(string texPathFormat, int fileIndex, int channelIndex) + { + var texFile = ExtractResult(ExtractResult(this.texFiles[texPathFormat])[fileIndex]); + var numPixels = texFile.Header.Width * texFile.Header.Height; + + _ = Service.Get(); + var targetIsB4G4R4A4 = this.InterfaceManager.SupportsDxgiFormat(Format.B4G4R4A4_UNorm); + var bpp = targetIsB4G4R4A4 ? 2 : 4; + var buffer = ArrayPool.Shared.Rent(numPixels * bpp); + try + { + var sliceSpan = texFile.SliceSpan(0, 0, out _, out _, out _); + switch (texFile.Header.Format) + { + case TexFile.TextureFormat.B4G4R4A4: + // Game ships with this format. + ExtractChannelFromB4G4R4A4(buffer, sliceSpan, channelIndex, targetIsB4G4R4A4); + break; + case TexFile.TextureFormat.B8G8R8A8: + // In case of modded font textures. + ExtractChannelFromB8G8R8A8(buffer, sliceSpan, channelIndex, targetIsB4G4R4A4); + break; + default: + // Unlikely. + ExtractChannelFromB8G8R8A8(buffer, texFile.ImageData, channelIndex, targetIsB4G4R4A4); + break; + } + + return this.scopedFinalizer.Add( + this.InterfaceManager.LoadImageFromDxgiFormat( + buffer, + texFile.Header.Width * bpp, + texFile.Header.Width, + texFile.Header.Height, + targetIsB4G4R4A4 ? Format.B4G4R4A4_UNorm : Format.B8G8R8A8_UNorm)); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs new file mode 100644 index 000000000..47254a5c9 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs @@ -0,0 +1,295 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Utility; + +using ImGuiNET; + +using Serilog; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Default implementation for . +/// +internal abstract class FontHandle : IFontHandle +{ + private const int NonMainThreadFontAccessWarningCheckInterval = 10000; + private static readonly ConditionalWeakTable NonMainThreadFontAccessWarning = new(); + private static long nextNonMainThreadFontAccessWarningCheck; + + private readonly InterfaceManager interfaceManager; + private readonly List pushedFonts = new(8); + + private IFontHandleManager? manager; + private long lastCumulativePresentCalls; + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + protected FontHandle(IFontHandleManager manager) + { + this.interfaceManager = Service.Get(); + this.manager = manager; + } + + /// + public event IFontHandle.ImFontChangedDelegate? ImFontChanged; + + /// + /// Event to be called on the first call. + /// + protected event Action? Disposed; + + /// + public Exception? LoadException => this.Manager.Substance?.GetBuildException(this); + + /// + public bool Available => (this.Manager.Substance?.GetFontPtr(this) ?? default).IsNotNullAndLoaded(); + + /// + /// Gets the associated . + /// + /// When the object has already been disposed. + protected IFontHandleManager Manager => this.manager ?? throw new ObjectDisposedException(this.GetType().Name); + + /// + public void Dispose() + { + if (this.manager is null) + return; + + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Invokes . + /// + /// The font, locked during the call of . + public void InvokeImFontChanged(ILockedImFont font) + { + try + { + this.ImFontChanged?.Invoke(this, font); + } + catch (Exception e) + { + Log.Error(e, $"{nameof(this.InvokeImFontChanged)}: error"); + } + } + + /// + /// Obtains an instance of corresponding to this font handle, + /// to be released after rendering the current frame. + /// + /// The font pointer, or default if unavailble. + /// + /// Behavior is undefined on access outside the main thread. + /// + public ImFontPtr LockUntilPostFrame() + { + if (this.TryLock(out _) is not { } locked) + return default; + + if (!ThreadSafety.IsMainThread && nextNonMainThreadFontAccessWarningCheck < Environment.TickCount64) + { + nextNonMainThreadFontAccessWarningCheck = + Environment.TickCount64 + NonMainThreadFontAccessWarningCheckInterval; + var stack = new StackTrace(); + if (Service.GetNullable()?.FindCallingPlugin(stack) is { } plugin) + { + if (!NonMainThreadFontAccessWarning.TryGetValue(plugin, out _)) + { + NonMainThreadFontAccessWarning.Add(plugin, new()); + Log.Warning( + "[IM] {pluginName}: Accessing fonts outside the main thread is deprecated.\n{stack}", + plugin.Name, + stack); + } + } + else + { + // Dalamud internal should be made safe right now + throw new InvalidOperationException("Attempted to access fonts outside the main thread."); + } + } + + this.interfaceManager.EnqueueDeferredDispose(locked); + return locked.ImFont; + } + + /// + /// Attempts to lock the fully constructed instance of corresponding to the this + /// , for use in any thread.
+ /// Modification of the font will exhibit undefined behavior if some other thread also uses the font. + ///
+ /// The error message, if any. + /// + /// An instance of that must be disposed after use on success; + /// null with populated on failure. + /// + /// Still may be thrown. + public ILockedImFont? TryLock(out string? errorMessage) + { + IFontHandleSubstance? prevSubstance = default; + while (true) + { + var substance = this.Manager.Substance; + + // Does the associated IFontAtlas have a built substance? + if (substance is null) + { + errorMessage = "The font atlas has not been built yet."; + return null; + } + + // Did we loop (because it did not have the requested font), + // and are the fetched substance same between loops? + if (substance == prevSubstance) + { + errorMessage = "The font atlas did not built the requested handle yet."; + return null; + } + + prevSubstance = substance; + + // Try to lock the substance. + try + { + substance.DataRoot.AddRef(); + } + catch (ObjectDisposedException) + { + // If it got invalidated, it's probably because a new substance is incoming. Try again. + continue; + } + + var fontPtr = substance.GetFontPtr(this); + if (fontPtr.IsNull()) + { + // The font for the requested handle is unavailable. Release the reference and try again. + substance.DataRoot.Release(); + continue; + } + + // Transfer the ownership of reference. + errorMessage = null; + return new LockedImFont(fontPtr, substance.DataRoot); + } + } + + /// + public ILockedImFont Lock() => + this.TryLock(out var errorMessage) ?? throw new InvalidOperationException(errorMessage); + + /// + public IDisposable Push() + { + ThreadSafety.AssertMainThread(); + + // Warn if the client is not properly managing the pushed font stack. + var cumulativePresentCalls = this.interfaceManager.CumulativePresentCalls; + if (this.lastCumulativePresentCalls != cumulativePresentCalls) + { + this.lastCumulativePresentCalls = cumulativePresentCalls; + if (this.pushedFonts.Count > 0) + { + Log.Warning( + $"{nameof(this.Push)} has been called, but the handle-private stack was not empty. " + + $"You might be missing a call to {nameof(this.Pop)}."); + this.pushedFonts.Clear(); + } + } + + var font = default(ImFontPtr); + if (this.TryLock(out _) is { } locked) + { + font = locked.ImFont; + this.interfaceManager.EnqueueDeferredDispose(locked); + } + + var rented = SimplePushedFont.Rent(this.pushedFonts, font); + this.pushedFonts.Add(rented); + return rented; + } + + /// + public void Pop() + { + ThreadSafety.AssertMainThread(); + this.pushedFonts[^1].Dispose(); + } + + /// + public Task WaitAsync() + { + if (this.Available) + return Task.FromResult(this); + + var tcs = new TaskCompletionSource(); + this.ImFontChanged += OnImFontChanged; + this.Disposed += OnDisposed; + if (this.Available) + OnImFontChanged(this, null); + return tcs.Task; + + void OnImFontChanged(IFontHandle unused, ILockedImFont? unused2) + { + if (tcs.Task.IsCompletedSuccessfully) + return; + + this.ImFontChanged -= OnImFontChanged; + this.Disposed -= OnDisposed; + try + { + tcs.SetResult(this); + } + catch + { + // ignore + } + } + + void OnDisposed() + { + if (tcs.Task.IsCompletedSuccessfully) + return; + + this.ImFontChanged -= OnImFontChanged; + this.Disposed -= OnDisposed; + try + { + tcs.SetException(new ObjectDisposedException(nameof(GamePrebakedFontHandle))); + } + catch + { + // ignore + } + } + } + + /// + /// Implementation for . + /// + /// If true, then the function is being called from . + protected void Dispose(bool disposing) + { + if (disposing) + { + if (this.pushedFonts.Count > 0) + Log.Warning($"{nameof(IFontHandle)}.{nameof(IDisposable.Dispose)}: fonts were still in a stack."); + this.Manager.FreeFontHandle(this); + this.manager = null; + this.Disposed?.InvokeSafely(); + this.ImFontChanged = null; + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/GamePrebakedFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/GamePrebakedFontHandle.cs new file mode 100644 index 000000000..b6c9817aa --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/GamePrebakedFontHandle.cs @@ -0,0 +1,877 @@ +using System.Buffers; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reactive.Disposables; + +using Dalamud.Game.Text; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Utility; +using Dalamud.Utility; + +using ImGuiNET; + +using Lumina.Data.Files; + +using Vector4 = System.Numerics.Vector4; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// A font handle that uses the game's built-in fonts, optionally with some styling. +/// +internal class GamePrebakedFontHandle : FontHandle +{ + /// + /// The smallest value of . + /// + public static readonly char SeIconCharMin = (char)Enum.GetValues().Min(); + + /// + /// The largest value of . + /// + public static readonly char SeIconCharMax = (char)Enum.GetValues().Max(); + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// Font to use. + public GamePrebakedFontHandle(IFontHandleManager manager, GameFontStyle style) + : base(manager) + { + if (!Enum.IsDefined(style.FamilyAndSize) || style.FamilyAndSize == GameFontFamilyAndSize.Undefined) + throw new ArgumentOutOfRangeException(nameof(style), style, null); + + if (style.SizePt <= 0) + throw new ArgumentException($"{nameof(style.SizePt)} must be a positive number.", nameof(style)); + + this.FontStyle = style; + } + + /// + /// Provider for for `common/font/fontNN.tex`. + /// + public interface IGameFontTextureProvider + { + /// + /// Creates the for the .
+ /// Dispose after use. + ///
+ /// The font family and size. + /// The view. + /// Dispose this after use.. + public MemoryHandle CreateFdtFileView(GameFontFamilyAndSize gffas, out FdtFileView fdtFileView); + + /// + /// Gets the number of font textures. + /// + /// Format of .tex path. + /// The number of textures. + public int GetFontTextureCount(string texPathFormat); + + /// + /// Gets the for the given index of a font. + /// + /// Format of .tex path. + /// The index of .tex file. + /// The . + public TexFile GetTexFile(string texPathFormat, int index); + + /// + /// Gets a new reference of the font texture. + /// + /// Format of .tex path. + /// Texture index. + /// The texture. + public IDalamudTextureWrap NewFontTextureRef(string texPathFormat, int textureIndex); + } + + /// + /// Gets the font style. + /// + public GameFontStyle FontStyle { get; } + + /// + public override string ToString() => $"{nameof(GamePrebakedFontHandle)}({this.FontStyle})"; + + /// + /// Manager for s. + /// + internal sealed class HandleManager : IFontHandleManager + { + private readonly Dictionary gameFontsRc = new(); + private readonly HashSet handles = new(); + private readonly object syncRoot = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The name of the owner atlas. + /// An instance of . + public HandleManager(string atlasName, IGameFontTextureProvider gameFontTextureProvider) + { + this.GameFontTextureProvider = gameFontTextureProvider; + this.Name = $"{atlasName}:{nameof(GamePrebakedFontHandle)}:Manager"; + } + + /// + public event Action? RebuildRecommend; + + /// + public string Name { get; } + + /// + public IFontHandleSubstance? Substance { get; set; } + + /// + /// Gets an instance of . + /// + public IGameFontTextureProvider GameFontTextureProvider { get; } + + /// + public void Dispose() + { + // empty + } + + /// + public IFontHandle NewFontHandle(GameFontStyle style) + { + var handle = new GamePrebakedFontHandle(this, style); + bool suggestRebuild; + lock (this.syncRoot) + { + this.handles.Add(handle); + this.gameFontsRc[style] = this.gameFontsRc.GetValueOrDefault(style, 0) + 1; + suggestRebuild = this.Substance?.GetFontPtr(handle).IsNotNullAndLoaded() is not true; + } + + if (suggestRebuild) + this.RebuildRecommend?.Invoke(); + + return handle; + } + + /// + public void FreeFontHandle(IFontHandle handle) + { + if (handle is not GamePrebakedFontHandle ggfh) + return; + + lock (this.syncRoot) + { + this.handles.Remove(ggfh); + if (!this.gameFontsRc.ContainsKey(ggfh.FontStyle)) + return; + + if ((this.gameFontsRc[ggfh.FontStyle] -= 1) == 0) + this.gameFontsRc.Remove(ggfh.FontStyle); + } + } + + /// + public IFontHandleSubstance NewSubstance(IRefCountable dataRoot) + { + lock (this.syncRoot) + return new HandleSubstance(this, dataRoot, this.handles.ToArray(), this.gameFontsRc.Keys); + } + } + + /// + /// Substance from . + /// + internal sealed class HandleSubstance : IFontHandleSubstance + { + private readonly HandleManager handleManager; + private readonly HashSet gameFontStyles; + + // Owned by this class, but ImFontPtr values still do not belong to this. + private readonly Dictionary fonts = new(); + private readonly Dictionary buildExceptions = new(); + private readonly List<(ImFontPtr Font, GameFontStyle Style, ushort[]? Ranges)> attachments = new(); + + private readonly HashSet templatedFonts = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The manager. + /// The data root. + /// The relevant handles. + /// The game font styles. + public HandleSubstance( + HandleManager manager, + IRefCountable dataRoot, + GamePrebakedFontHandle[] relevantHandles, + IEnumerable gameFontStyles) + { + // We do not call dataRoot.AddRef; this object is dependant on lifetime of dataRoot. + + this.handleManager = manager; + this.DataRoot = dataRoot; + this.RelevantHandles = relevantHandles; + this.gameFontStyles = new(gameFontStyles); + } + + /// + /// Gets the relevant handles. + /// + // Not owned by this class. Do not dispose. + public GamePrebakedFontHandle[] RelevantHandles { get; } + + /// + ICollection IFontHandleSubstance.RelevantHandles => this.RelevantHandles; + + /// + public IRefCountable DataRoot { get; } + + /// + public IFontHandleManager Manager => this.handleManager; + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public IFontAtlasBuildToolkitPreBuild? PreBuildToolkitForApi9Compat { get; set; } + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public bool CreateFontOnAccess { get; set; } + + /// + public void Dispose() + { + // empty + } + + /// + /// Attaches game symbols to the given font. If font is null, it will be created. + /// + /// The toolkitPostBuild. + /// The font to attach to. + /// The game font style. + /// The intended glyph ranges. + /// if it is not empty; otherwise a new font. + public ImFontPtr AttachGameGlyphs( + IFontAtlasBuildToolkitPreBuild toolkitPreBuild, + ImFontPtr font, + GameFontStyle style, + ushort[]? glyphRanges = null) + { + if (font.IsNull()) + font = this.CreateTemplateFont(toolkitPreBuild, style.SizePx); + this.attachments.Add((font, style, glyphRanges)); + return font; + } + + /// + /// Creates or gets a relevant for the given . + /// + /// The game font style. + /// The toolkitPostBuild. + /// The font. + public ImFontPtr GetOrCreateFont(GameFontStyle style, IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + try + { + if (!this.fonts.TryGetValue(style, out var plan)) + { + plan = new( + style, + toolkitPreBuild.Scale, + this.handleManager.GameFontTextureProvider, + this.CreateTemplateFont(toolkitPreBuild, style.SizePx)); + this.fonts[style] = plan; + } + + plan.AttachFont(plan.FullRangeFont); + return plan.FullRangeFont; + } + catch (Exception e) + { + this.buildExceptions[style] = e; + throw; + } + } + + // Use this on API 10. + // /// + // public ImFontPtr GetFontPtr(IFontHandle handle) => + // handle is GamePrebakedFontHandle ggfh + // ? this.fonts.GetValueOrDefault(ggfh.FontStyle)?.FullRangeFont ?? default + // : default; + + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public ImFontPtr GetFontPtr(IFontHandle handle) + { + if (handle is not GamePrebakedFontHandle ggfh) + return default; + if (this.fonts.GetValueOrDefault(ggfh.FontStyle)?.FullRangeFont is { } font) + return font; + if (!this.CreateFontOnAccess) + return default; + if (this.PreBuildToolkitForApi9Compat is not { } tk) + return default; + return this.GetOrCreateFont(ggfh.FontStyle, tk); + } + + /// + public Exception? GetBuildException(IFontHandle handle) => + handle is GamePrebakedFontHandle ggfh ? this.buildExceptions.GetValueOrDefault(ggfh.FontStyle) : default; + + /// + public void OnPreBuild(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + foreach (var style in this.gameFontStyles) + { + if (this.fonts.ContainsKey(style)) + continue; + + try + { + _ = this.GetOrCreateFont(style, toolkitPreBuild); + } + catch + { + // ignore; it should have been recorded from the call + } + } + } + + /// + public void OnPreBuildCleanup(IFontAtlasBuildToolkitPreBuild toolkitPreBuild) + { + foreach (var (font, style, ranges) in this.attachments) + { + var effectiveStyle = + toolkitPreBuild.IsGlobalScaleIgnored(font) + ? style.Scale(1 / toolkitPreBuild.Scale) + : style; + if (!this.fonts.TryGetValue(style, out var plan)) + { + plan = new( + effectiveStyle, + toolkitPreBuild.Scale, + this.handleManager.GameFontTextureProvider, + this.CreateTemplateFont(toolkitPreBuild, style.SizePx)); + this.fonts[style] = plan; + } + + plan.AttachFont(font, ranges); + } + + foreach (var plan in this.fonts.Values) + { + plan.EnsureGlyphs(toolkitPreBuild.NewImAtlas); + } + } + + /// + public unsafe void OnPostBuild(IFontAtlasBuildToolkitPostBuild toolkitPostBuild) + { + var allTextureIndices = new Dictionary(); + var allTexFiles = new Dictionary(); + using var rentReturn = Disposable.Create( + () => + { + foreach (var x in allTextureIndices.Values) + ArrayPool.Shared.Return(x); + foreach (var x in allTexFiles.Values) + ArrayPool.Shared.Return(x); + }); + + var pixels8Array = new byte*[toolkitPostBuild.NewImAtlas.Textures.Size]; + var widths = new int[toolkitPostBuild.NewImAtlas.Textures.Size]; + for (var i = 0; i < pixels8Array.Length; i++) + toolkitPostBuild.NewImAtlas.GetTexDataAsAlpha8(i, out pixels8Array[i], out widths[i], out _); + + foreach (var (style, plan) in this.fonts) + { + try + { + foreach (var font in plan.Ranges.Keys) + this.PatchFontMetricsIfNecessary(style, font, toolkitPostBuild.Scale); + + plan.SetFullRangeFontGlyphs(toolkitPostBuild, allTexFiles, allTextureIndices, pixels8Array, widths); + plan.CopyGlyphsToRanges(toolkitPostBuild); + plan.PostProcessFullRangeFont(toolkitPostBuild.Scale); + } + catch (Exception e) + { + this.buildExceptions[style] = e; + this.fonts[style] = default; + } + } + } + + /// + /// Creates a new template font. + /// + /// The toolkitPostBuild. + /// The size of the font. + /// The font. + private ImFontPtr CreateTemplateFont(IFontAtlasBuildToolkitPreBuild toolkitPreBuild, float sizePx) + { + var font = toolkitPreBuild.AddDalamudAssetFont( + DalamudAsset.NotoSansJpMedium, + new() + { + GlyphRanges = new ushort[] { ' ', ' ', '\0' }, + SizePx = sizePx, + }); + this.templatedFonts.Add(font); + return font; + } + + private unsafe void PatchFontMetricsIfNecessary(GameFontStyle style, ImFontPtr font, float atlasScale) + { + if (!this.templatedFonts.Contains(font)) + return; + + var fas = style.Scale(atlasScale).FamilyAndSize; + using var handle = this.handleManager.GameFontTextureProvider.CreateFdtFileView(fas, out var fdt); + ref var fdtFontHeader = ref fdt.FontHeader; + var fontPtr = font.NativePtr; + + var scale = style.SizePt / fdtFontHeader.Size; + fontPtr->Ascent = fdtFontHeader.Ascent * scale; + fontPtr->Descent = fdtFontHeader.Descent * scale; + fontPtr->EllipsisChar = '…'; + } + } + + [SuppressMessage( + "StyleCop.CSharp.MaintainabilityRules", + "SA1401:Fields should be private", + Justification = "Internal")] + private sealed class FontDrawPlan : IDisposable + { + public readonly GameFontStyle Style; + public readonly GameFontStyle BaseStyle; + public readonly GameFontFamilyAndSizeAttribute BaseAttr; + public readonly int TexCount; + public readonly Dictionary Ranges = new(); + public readonly List<(int RectId, int FdtGlyphIndex)> Rects = new(); + public readonly ushort[] RectLookup = new ushort[0x10000]; + public readonly FdtFileView Fdt; + public readonly ImFontPtr FullRangeFont; + + private readonly IDisposable fdtHandle; + private readonly IGameFontTextureProvider gftp; + + public FontDrawPlan( + GameFontStyle style, + float scale, + IGameFontTextureProvider gameFontTextureProvider, + ImFontPtr fullRangeFont) + { + this.Style = style; + this.BaseStyle = style.Scale(scale); + this.BaseAttr = this.BaseStyle.FamilyAndSize.GetAttribute()!; + this.gftp = gameFontTextureProvider; + this.TexCount = this.gftp.GetFontTextureCount(this.BaseAttr.TexPathFormat); + this.fdtHandle = this.gftp.CreateFdtFileView(this.BaseStyle.FamilyAndSize, out this.Fdt); + this.RectLookup.AsSpan().Fill(ushort.MaxValue); + this.FullRangeFont = fullRangeFont; + this.Ranges[fullRangeFont] = new(0x10000); + } + + public void Dispose() + { + this.fdtHandle.Dispose(); + } + + public void AttachFont(ImFontPtr font, ushort[]? glyphRanges = null) + { + if (!this.Ranges.TryGetValue(font, out var rangeBitArray)) + rangeBitArray = this.Ranges[font] = new(0x10000); + + if (glyphRanges is null) + { + foreach (ref var g in this.Fdt.Glyphs) + { + var c = g.CharInt; + if (c is >= 0x20 and <= 0xFFFE) + rangeBitArray[c] = true; + } + + return; + } + + for (var i = 0; i < glyphRanges.Length - 1; i += 2) + { + if (glyphRanges[i] == 0) + break; + var from = (int)glyphRanges[i]; + var to = (int)glyphRanges[i + 1]; + for (var j = from; j <= to; j++) + rangeBitArray[j] = true; + } + } + + public unsafe void EnsureGlyphs(ImFontAtlasPtr atlas) + { + var glyphs = this.Fdt.Glyphs; + var ranges = this.Ranges[this.FullRangeFont]; + foreach (var (font, extraRange) in this.Ranges) + { + if (font.NativePtr != this.FullRangeFont.NativePtr) + ranges.Or(extraRange); + } + + if (this.Style is not { Weight: 0, SkewStrength: 0 }) + { + for (var fdtGlyphIndex = 0; fdtGlyphIndex < glyphs.Length; fdtGlyphIndex++) + { + ref var glyph = ref glyphs[fdtGlyphIndex]; + var cint = glyph.CharInt; + if (cint > char.MaxValue) + continue; + if (!ranges[cint] || this.RectLookup[cint] != ushort.MaxValue) + continue; + + var widthAdjustment = this.BaseStyle.CalculateBaseWidthAdjustment(this.Fdt.FontHeader, glyph); + this.RectLookup[cint] = (ushort)this.Rects.Count; + this.Rects.Add( + ( + atlas.AddCustomRectFontGlyph( + this.FullRangeFont, + (char)cint, + glyph.BoundingWidth + widthAdjustment, + glyph.BoundingHeight, + glyph.AdvanceWidth, + new(this.BaseAttr.HorizontalOffset, glyph.CurrentOffsetY)), + fdtGlyphIndex)); + } + } + else + { + for (var fdtGlyphIndex = 0; fdtGlyphIndex < glyphs.Length; fdtGlyphIndex++) + { + ref var glyph = ref glyphs[fdtGlyphIndex]; + var cint = glyph.CharInt; + if (cint > char.MaxValue) + continue; + if (!ranges[cint] || this.RectLookup[cint] != ushort.MaxValue) + continue; + + this.RectLookup[cint] = (ushort)this.Rects.Count; + this.Rects.Add((-1, fdtGlyphIndex)); + } + } + } + + public unsafe void PostProcessFullRangeFont(float atlasScale) + { + var round = 1 / atlasScale; + var pfrf = this.FullRangeFont.NativePtr; + ref var frf = ref *pfrf; + + frf.FontSize = MathF.Round(frf.FontSize / round) * round; + frf.Ascent = MathF.Round(frf.Ascent / round) * round; + frf.Descent = MathF.Round(frf.Descent / round) * round; + + var scale = this.Style.SizePt / this.Fdt.FontHeader.Size; + foreach (ref var g in this.FullRangeFont.GlyphsWrapped().DataSpan) + { + var w = (g.X1 - g.X0) * scale; + var h = (g.Y1 - g.Y0) * scale; + g.X0 = MathF.Round((g.X0 * scale) / round) * round; + g.Y0 = MathF.Round((g.Y0 * scale) / round) * round; + g.X1 = g.X0 + w; + g.Y1 = g.Y0 + h; + g.AdvanceX = MathF.Round((g.AdvanceX * scale) / round) * round; + } + + var fullRange = this.Ranges[this.FullRangeFont]; + foreach (ref var k in this.Fdt.PairAdjustments) + { + var (leftInt, rightInt) = (k.LeftInt, k.RightInt); + if (leftInt > char.MaxValue || rightInt > char.MaxValue) + continue; + if (!fullRange[leftInt] || !fullRange[rightInt]) + continue; + ImGuiNative.ImFont_AddKerningPair( + pfrf, + (ushort)leftInt, + (ushort)rightInt, + MathF.Round((k.RightOffset * scale) / round) * round); + } + + pfrf->FallbackGlyph = null; + ImGuiNative.ImFont_BuildLookupTable(pfrf); + + foreach (var fallbackCharCandidate in FontAtlasFactory.FallbackCodepoints) + { + var glyph = ImGuiNative.ImFont_FindGlyphNoFallback(pfrf, fallbackCharCandidate); + if ((nint)glyph == IntPtr.Zero) + continue; + frf.FallbackChar = fallbackCharCandidate; + frf.FallbackGlyph = glyph; + frf.FallbackHotData = + (ImFontGlyphHotData*)frf.IndexedHotData.Address( + fallbackCharCandidate); + break; + } + } + + public unsafe void CopyGlyphsToRanges(IFontAtlasBuildToolkitPostBuild toolkitPostBuild) + { + var scale = this.Style.SizePt / this.Fdt.FontHeader.Size; + var atlasScale = toolkitPostBuild.Scale; + var round = 1 / atlasScale; + + foreach (var (font, rangeBits) in this.Ranges) + { + if (font.NativePtr == this.FullRangeFont.NativePtr) + continue; + + var noGlobalScale = toolkitPostBuild.IsGlobalScaleIgnored(font); + + var lookup = font.IndexLookupWrapped(); + var glyphs = font.GlyphsWrapped(); + foreach (ref var sourceGlyph in this.FullRangeFont.GlyphsWrapped().DataSpan) + { + if (!rangeBits[sourceGlyph.Codepoint]) + continue; + + var glyphIndex = ushort.MaxValue; + if (sourceGlyph.Codepoint < lookup.Length) + glyphIndex = lookup[sourceGlyph.Codepoint]; + + if (glyphIndex == ushort.MaxValue) + { + glyphIndex = (ushort)glyphs.Length; + glyphs.Add(default); + } + + ref var g = ref glyphs[glyphIndex]; + g = sourceGlyph; + if (noGlobalScale) + { + g.XY *= scale; + g.AdvanceX *= scale; + } + else + { + var w = (g.X1 - g.X0) * scale; + var h = (g.Y1 - g.Y0) * scale; + g.X0 = MathF.Round((g.X0 * scale) / round) * round; + g.Y0 = MathF.Round((g.Y0 * scale) / round) * round; + g.X1 = g.X0 + w; + g.Y1 = g.Y0 + h; + g.AdvanceX = MathF.Round((g.AdvanceX * scale) / round) * round; + } + } + + foreach (ref var k in this.Fdt.PairAdjustments) + { + var (leftInt, rightInt) = (k.LeftInt, k.RightInt); + if (leftInt > char.MaxValue || rightInt > char.MaxValue) + continue; + if (!rangeBits[leftInt] || !rangeBits[rightInt]) + continue; + if (noGlobalScale) + { + font.AddKerningPair((ushort)leftInt, (ushort)rightInt, k.RightOffset * scale); + } + else + { + font.AddKerningPair( + (ushort)leftInt, + (ushort)rightInt, + MathF.Round((k.RightOffset * scale) / round) * round); + } + } + + font.NativePtr->FallbackGlyph = null; + font.BuildLookupTable(); + + foreach (var fallbackCharCandidate in FontAtlasFactory.FallbackCodepoints) + { + var glyph = font.FindGlyphNoFallback(fallbackCharCandidate).NativePtr; + if ((nint)glyph == IntPtr.Zero) + continue; + + ref var frf = ref *font.NativePtr; + frf.FallbackChar = fallbackCharCandidate; + frf.FallbackGlyph = glyph; + frf.FallbackHotData = + (ImFontGlyphHotData*)frf.IndexedHotData.Address( + fallbackCharCandidate); + break; + } + } + } + + public unsafe void SetFullRangeFontGlyphs( + IFontAtlasBuildToolkitPostBuild toolkitPostBuild, + Dictionary allTexFiles, + Dictionary allTextureIndices, + byte*[] pixels8Array, + int[] widths) + { + var glyphs = this.FullRangeFont.GlyphsWrapped(); + var lookups = this.FullRangeFont.IndexLookupWrapped(); + + ref var fdtFontHeader = ref this.Fdt.FontHeader; + var fdtGlyphs = this.Fdt.Glyphs; + var fdtTexSize = new Vector4( + this.Fdt.FontHeader.TextureWidth, + this.Fdt.FontHeader.TextureHeight, + this.Fdt.FontHeader.TextureWidth, + this.Fdt.FontHeader.TextureHeight); + + if (!allTexFiles.TryGetValue(this.BaseAttr.TexPathFormat, out var texFiles)) + { + allTexFiles.Add( + this.BaseAttr.TexPathFormat, + texFiles = ArrayPool.Shared.Rent(this.TexCount)); + } + + if (!allTextureIndices.TryGetValue(this.BaseAttr.TexPathFormat, out var textureIndices)) + { + allTextureIndices.Add( + this.BaseAttr.TexPathFormat, + textureIndices = ArrayPool.Shared.Rent(this.TexCount)); + textureIndices.AsSpan(0, this.TexCount).Fill(-1); + } + + var pixelWidth = Math.Max(1, (int)MathF.Ceiling(this.BaseStyle.Weight + 1)); + var pixelStrength = stackalloc byte[pixelWidth]; + for (var i = 0; i < pixelWidth; i++) + pixelStrength[i] = (byte)(255 * Math.Min(1f, (this.BaseStyle.Weight + 1) - i)); + + var minGlyphY = 0; + var maxGlyphY = 0; + foreach (ref var g in fdtGlyphs) + { + minGlyphY = Math.Min(g.CurrentOffsetY, minGlyphY); + maxGlyphY = Math.Max(g.BoundingHeight + g.CurrentOffsetY, maxGlyphY); + } + + var horzShift = stackalloc int[maxGlyphY - minGlyphY]; + var horzBlend = stackalloc byte[maxGlyphY - minGlyphY]; + horzShift -= minGlyphY; + horzBlend -= minGlyphY; + if (this.BaseStyle.BaseSkewStrength != 0) + { + for (var i = minGlyphY; i < maxGlyphY; i++) + { + float blend = this.BaseStyle.BaseSkewStrength switch + { + > 0 => fdtFontHeader.LineHeight - i, + < 0 => -i, + _ => throw new InvalidOperationException(), + }; + blend *= this.BaseStyle.BaseSkewStrength / fdtFontHeader.LineHeight; + horzShift[i] = (int)MathF.Floor(blend); + horzBlend[i] = (byte)(255 * (blend - horzShift[i])); + } + } + + foreach (var (rectId, fdtGlyphIndex) in this.Rects) + { + ref var fdtGlyph = ref fdtGlyphs[fdtGlyphIndex]; + if (rectId == -1) + { + ref var textureIndex = ref textureIndices[fdtGlyph.TextureIndex]; + if (textureIndex == -1) + { + textureIndex = toolkitPostBuild.StoreTexture( + this.gftp.NewFontTextureRef(this.BaseAttr.TexPathFormat, fdtGlyph.TextureIndex), + true); + } + + var glyph = new ImGuiHelpers.ImFontGlyphReal + { + AdvanceX = fdtGlyph.AdvanceWidth, + Codepoint = fdtGlyph.Char, + Colored = false, + TextureIndex = textureIndex, + Visible = true, + X0 = this.BaseAttr.HorizontalOffset, + Y0 = fdtGlyph.CurrentOffsetY, + U0 = fdtGlyph.TextureOffsetX, + V0 = fdtGlyph.TextureOffsetY, + U1 = fdtGlyph.BoundingWidth, + V1 = fdtGlyph.BoundingHeight, + }; + + glyph.XY1 = glyph.XY0 + glyph.UV1; + glyph.UV1 += glyph.UV0; + glyph.UV /= fdtTexSize; + + glyphs.Add(glyph); + } + else + { + ref var rc = ref *(ImGuiHelpers.ImFontAtlasCustomRectReal*)toolkitPostBuild.NewImAtlas + .GetCustomRectByIndex(rectId) + .NativePtr; + var widthAdjustment = this.BaseStyle.CalculateBaseWidthAdjustment(fdtFontHeader, fdtGlyph); + + // Glyph is scaled at this point; undo that. + ref var glyph = ref glyphs[lookups[rc.GlyphId]]; + glyph.X0 = this.BaseAttr.HorizontalOffset; + glyph.Y0 = fdtGlyph.CurrentOffsetY; + glyph.X1 = glyph.X0 + fdtGlyph.BoundingWidth + widthAdjustment; + glyph.Y1 = glyph.Y0 + fdtGlyph.BoundingHeight; + glyph.AdvanceX = fdtGlyph.AdvanceWidth; + + var pixels8 = pixels8Array[rc.TextureIndex]; + var width = widths[rc.TextureIndex]; + texFiles[fdtGlyph.TextureFileIndex] ??= + this.gftp.GetTexFile(this.BaseAttr.TexPathFormat, fdtGlyph.TextureFileIndex); + var sourceBuffer = texFiles[fdtGlyph.TextureFileIndex].ImageData; + var sourceBufferDelta = fdtGlyph.TextureChannelByteIndex; + + for (var y = 0; y < fdtGlyph.BoundingHeight; y++) + { + var sourcePixelIndex = + ((fdtGlyph.TextureOffsetY + y) * fdtFontHeader.TextureWidth) + fdtGlyph.TextureOffsetX; + sourcePixelIndex *= 4; + sourcePixelIndex += sourceBufferDelta; + var blend1 = horzBlend[fdtGlyph.CurrentOffsetY + y]; + + var targetOffset = ((rc.Y + y) * width) + rc.X; + for (var x = 0; x < rc.Width; x++) + pixels8[targetOffset + x] = 0; + + targetOffset += horzShift[fdtGlyph.CurrentOffsetY + y]; + if (blend1 == 0) + { + for (var x = 0; x < fdtGlyph.BoundingWidth; x++, sourcePixelIndex += 4, targetOffset++) + { + var n = sourceBuffer[sourcePixelIndex + 4]; + for (var boldOffset = 0; boldOffset < pixelWidth; boldOffset++) + { + ref var p = ref pixels8[targetOffset + boldOffset]; + p = Math.Max(p, (byte)((pixelStrength[boldOffset] * n) / 255)); + } + } + } + else + { + var blend2 = 255 - blend1; + for (var x = 0; x < fdtGlyph.BoundingWidth; x++, sourcePixelIndex += 4, targetOffset++) + { + var a1 = sourceBuffer[sourcePixelIndex]; + var a2 = x == fdtGlyph.BoundingWidth - 1 ? 0 : sourceBuffer[sourcePixelIndex + 4]; + var n = (a1 * blend1) + (a2 * blend2); + + for (var boldOffset = 0; boldOffset < pixelWidth; boldOffset++) + { + ref var p = ref pixels8[targetOffset + boldOffset]; + p = Math.Max(p, (byte)((pixelStrength[boldOffset] * n) / 255 / 255)); + } + } + } + } + } + } + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleManager.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleManager.cs new file mode 100644 index 000000000..94976598a --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleManager.cs @@ -0,0 +1,35 @@ +using Dalamud.Utility; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Manager for . +/// +internal interface IFontHandleManager : IDisposable +{ + /// + event Action? RebuildRecommend; + + /// + /// Gets the name of the font handle manager. For logging and debugging purposes. + /// + string Name { get; } + + /// + /// Gets or sets the active font handle substance. + /// + IFontHandleSubstance? Substance { get; set; } + + /// + /// Decrease font reference counter. + /// + /// Handle being released. + void FreeFontHandle(IFontHandle handle); + + /// + /// Creates a new substance of the font atlas. + /// + /// The data root. + /// The new substance. + IFontHandleSubstance NewSubstance(IRefCountable dataRoot); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleSubstance.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleSubstance.cs new file mode 100644 index 000000000..62c893a48 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/IFontHandleSubstance.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Substance of a font. +/// +internal interface IFontHandleSubstance : IDisposable +{ + /// + /// Gets the data root relevant to this instance of . + /// + IRefCountable DataRoot { get; } + + /// + /// Gets the manager relevant to this instance of . + /// + IFontHandleManager Manager { get; } + + /// + /// Gets or sets the relevant for this. + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + IFontAtlasBuildToolkitPreBuild? PreBuildToolkitForApi9Compat { get; set; } + + /// + /// Gets or sets a value indicating whether to create a new instance of on first + /// access, for compatibility with API 9. + /// + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + bool CreateFontOnAccess { get; set; } + + /// + /// Gets the relevant handles. + /// + public ICollection RelevantHandles { get; } + + /// + /// Gets the font. + /// + /// The handle to get from. + /// Corresponding font or null. + ImFontPtr GetFontPtr(IFontHandle handle); + + /// + /// Gets the exception happened while loading for the font. + /// + /// The handle to get from. + /// Corresponding font or null. + Exception? GetBuildException(IFontHandle handle); + + /// + /// Called before call. + /// + /// The toolkit. + void OnPreBuild(IFontAtlasBuildToolkitPreBuild toolkitPreBuild); + + /// + /// Called between and calls.
+ /// Any further modification to will result in undefined behavior. + ///
+ /// The toolkit. + void OnPreBuildCleanup(IFontAtlasBuildToolkitPreBuild toolkitPreBuild); + + /// + /// Called after call. + /// + /// The toolkit. + void OnPostBuild(IFontAtlasBuildToolkitPostBuild toolkitPostBuild); +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/LockedImFont.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/LockedImFont.cs new file mode 100644 index 000000000..bd50502c8 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/LockedImFont.cs @@ -0,0 +1,62 @@ +using Dalamud.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// The implementation for . +/// +internal class LockedImFont : ILockedImFont +{ + private IRefCountable? owner; + + /// + /// Initializes a new instance of the class. + /// Ownership of reference of is transferred. + /// + /// The contained font. + /// The owner. + /// The rented instance of . + internal LockedImFont(ImFontPtr font, IRefCountable owner) + { + this.ImFont = font; + this.owner = owner; + } + + /// + /// Finalizes an instance of the class. + /// + ~LockedImFont() => this.FreeOwner(); + + /// + public ImFontPtr ImFont { get; private set; } + + /// + public ILockedImFont NewRef() + { + if (this.owner is null) + throw new ObjectDisposedException(nameof(LockedImFont)); + + var newRef = new LockedImFont(this.ImFont, this.owner); + this.owner.AddRef(); + return newRef; + } + + /// + public void Dispose() + { + this.FreeOwner(); + GC.SuppressFinalize(this); + } + + private void FreeOwner() + { + if (this.owner is null) + return; + + this.owner.Release(); + this.owner = null; + this.ImFont = default; + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/SimplePushedFont.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/SimplePushedFont.cs new file mode 100644 index 000000000..0c96025ac --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/SimplePushedFont.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Diagnostics; + +using Dalamud.Interface.Utility; + +using ImGuiNET; + +using Microsoft.Extensions.ObjectPool; + +using Serilog; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Reusable font push/popper. +/// +internal sealed class SimplePushedFont : IDisposable +{ + // Using constructor instead of DefaultObjectPoolProvider, since we do not want the pool to call Dispose. + private static readonly ObjectPool Pool = + new DefaultObjectPool(new DefaultPooledObjectPolicy()); + + private List? stack; + private ImFontPtr font; + + /// + /// Pushes the font, and return an instance of . + /// + /// The -private stack. + /// The font pointer being pushed. + /// The rented instance of . + public static SimplePushedFont Rent(List stack, ImFontPtr fontPtr) + { + var rented = Pool.Get(); + Debug.Assert(rented.font.IsNull(), "Rented object must not have its font set"); + rented.stack = stack; + + if (fontPtr.IsNotNullAndLoaded()) + { + rented.font = fontPtr; + ImGui.PushFont(fontPtr); + } + + return rented; + } + + /// + public unsafe void Dispose() + { + if (this.stack is null || !ReferenceEquals(this.stack[^1], this)) + { + throw new InvalidOperationException("Tried to pop a non-pushed font."); + } + + this.stack.RemoveAt(this.stack.Count - 1); + + if (!this.font.IsNull()) + { + if (ImGui.GetFont().NativePtr == this.font.NativePtr) + { + ImGui.PopFont(); + } + else + { + Log.Warning( + $"{nameof(IFontHandle.Pop)}: The font currently being popped does not match the pushed font. " + + $"Doing nothing."); + } + } + + this.font = default; + this.stack = null; + Pool.Return(this); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Common.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Common.cs new file mode 100644 index 000000000..8e7149853 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Common.cs @@ -0,0 +1,203 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + private struct Fixed : IComparable + { + public ushort Major; + public ushort Minor; + + public Fixed(ushort major, ushort minor) + { + this.Major = major; + this.Minor = minor; + } + + public Fixed(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.Major); + span.ReadBig(ref offset, out this.Minor); + } + + public int CompareTo(Fixed other) + { + var majorComparison = this.Major.CompareTo(other.Major); + return majorComparison != 0 ? majorComparison : this.Minor.CompareTo(other.Minor); + } + } + + private struct KerningPair : IEquatable + { + public ushort Left; + public ushort Right; + public short Value; + + public KerningPair(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.Left); + span.ReadBig(ref offset, out this.Right); + span.ReadBig(ref offset, out this.Value); + } + + public KerningPair(ushort left, ushort right, short value) + { + this.Left = left; + this.Right = right; + this.Value = value; + } + + public static bool operator ==(KerningPair left, KerningPair right) => left.Equals(right); + + public static bool operator !=(KerningPair left, KerningPair right) => !left.Equals(right); + + public static KerningPair ReverseEndianness(KerningPair pair) => new() + { + Left = BinaryPrimitives.ReverseEndianness(pair.Left), + Right = BinaryPrimitives.ReverseEndianness(pair.Right), + Value = BinaryPrimitives.ReverseEndianness(pair.Value), + }; + + public bool Equals(KerningPair other) => + this.Left == other.Left && this.Right == other.Right && this.Value == other.Value; + + public override bool Equals(object? obj) => obj is KerningPair other && this.Equals(other); + + public override int GetHashCode() => HashCode.Combine(this.Left, this.Right, this.Value); + + public override string ToString() => $"KerningPair[{this.Left}, {this.Right}] = {this.Value}"; + } + + [StructLayout(LayoutKind.Explicit, Size = 4)] + private struct PlatformAndEncoding + { + [FieldOffset(0)] + public PlatformId Platform; + + [FieldOffset(2)] + public UnicodeEncodingId UnicodeEncoding; + + [FieldOffset(2)] + public MacintoshEncodingId MacintoshEncoding; + + [FieldOffset(2)] + public IsoEncodingId IsoEncoding; + + [FieldOffset(2)] + public WindowsEncodingId WindowsEncoding; + + public PlatformAndEncoding(PointerSpan source) + { + var offset = 0; + source.ReadBig(ref offset, out this.Platform); + source.ReadBig(ref offset, out this.UnicodeEncoding); + } + + public static PlatformAndEncoding ReverseEndianness(PlatformAndEncoding value) => new() + { + Platform = (PlatformId)BinaryPrimitives.ReverseEndianness((ushort)value.Platform), + UnicodeEncoding = (UnicodeEncodingId)BinaryPrimitives.ReverseEndianness((ushort)value.UnicodeEncoding), + }; + + public readonly string Decode(Span data) + { + switch (this.Platform) + { + case PlatformId.Unicode: + switch (this.UnicodeEncoding) + { + case UnicodeEncodingId.Unicode_2_0_Bmp: + case UnicodeEncodingId.Unicode_2_0_Full: + return Encoding.BigEndianUnicode.GetString(data); + } + + break; + + case PlatformId.Macintosh: + switch (this.MacintoshEncoding) + { + case MacintoshEncodingId.Roman: + return Encoding.ASCII.GetString(data); + } + + break; + + case PlatformId.Windows: + switch (this.WindowsEncoding) + { + case WindowsEncodingId.Symbol: + case WindowsEncodingId.UnicodeBmp: + case WindowsEncodingId.UnicodeFullRepertoire: + return Encoding.BigEndianUnicode.GetString(data); + } + + break; + } + + throw new NotSupportedException(); + } + } + + [StructLayout(LayoutKind.Explicit)] + private struct TagStruct : IEquatable, IComparable + { + [FieldOffset(0)] + public unsafe fixed byte Tag[4]; + + [FieldOffset(0)] + public uint NativeValue; + + public unsafe TagStruct(char c1, char c2, char c3, char c4) + { + this.Tag[0] = checked((byte)c1); + this.Tag[1] = checked((byte)c2); + this.Tag[2] = checked((byte)c3); + this.Tag[3] = checked((byte)c4); + } + + public unsafe TagStruct(PointerSpan span) + { + this.Tag[0] = span[0]; + this.Tag[1] = span[1]; + this.Tag[2] = span[2]; + this.Tag[3] = span[3]; + } + + public unsafe TagStruct(ReadOnlySpan span) + { + this.Tag[0] = span[0]; + this.Tag[1] = span[1]; + this.Tag[2] = span[2]; + this.Tag[3] = span[3]; + } + + public unsafe byte this[int index] + { + get => this.Tag[index]; + set => this.Tag[index] = value; + } + + public static bool operator ==(TagStruct left, TagStruct right) => left.Equals(right); + + public static bool operator !=(TagStruct left, TagStruct right) => !left.Equals(right); + + public bool Equals(TagStruct other) => this.NativeValue == other.NativeValue; + + public override bool Equals(object? obj) => obj is TagStruct other && this.Equals(other); + + public override int GetHashCode() => (int)this.NativeValue; + + public int CompareTo(TagStruct other) => this.NativeValue.CompareTo(other.NativeValue); + + public override unsafe string ToString() => + $"0x{this.NativeValue:08X} \"{(char)this.Tag[0]}{(char)this.Tag[1]}{(char)this.Tag[2]}{(char)this.Tag[3]}\""; + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Enums.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Enums.cs new file mode 100644 index 000000000..f6a653a51 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Enums.cs @@ -0,0 +1,84 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Version name in enum value names")] + private enum IsoEncodingId : ushort + { + Ascii = 0, + Iso_10646 = 1, + Iso_8859_1 = 2, + } + + private enum MacintoshEncodingId : ushort + { + Roman = 0, + } + + private enum NameId : ushort + { + CopyrightNotice = 0, + FamilyName = 1, + SubfamilyName = 2, + UniqueId = 3, + FullFontName = 4, + VersionString = 5, + PostScriptName = 6, + Trademark = 7, + Manufacturer = 8, + Designer = 9, + Description = 10, + UrlVendor = 11, + UrlDesigner = 12, + LicenseDescription = 13, + LicenseInfoUrl = 14, + TypographicFamilyName = 16, + TypographicSubfamilyName = 17, + CompatibleFullMac = 18, + SampleText = 19, + PoscSriptCidFindFontName = 20, + WwsFamilyName = 21, + WwsSubfamilyName = 22, + LightBackgroundPalette = 23, + DarkBackgroundPalette = 24, + VariationPostScriptNamePrefix = 25, + } + + private enum PlatformId : ushort + { + Unicode = 0, + Macintosh = 1, // discouraged + Iso = 2, // deprecated + Windows = 3, + Custom = 4, // OTF Windows NT compatibility mapping + } + + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Version name in enum value names")] + private enum UnicodeEncodingId : ushort + { + Unicode_1_0 = 0, // deprecated + Unicode_1_1 = 1, // deprecated + IsoIec_10646 = 2, // deprecated + Unicode_2_0_Bmp = 3, + Unicode_2_0_Full = 4, + UnicodeVariationSequences = 5, + UnicodeFullRepertoire = 6, + } + + private enum WindowsEncodingId : ushort + { + Symbol = 0, + UnicodeBmp = 1, + ShiftJis = 2, + Prc = 3, + Big5 = 4, + Wansung = 5, + Johab = 6, + UnicodeFullRepertoire = 10, + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Files.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Files.cs new file mode 100644 index 000000000..3d89dd806 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Files.cs @@ -0,0 +1,148 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +[SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "TrueType specification defined fields")] +[SuppressMessage("ReSharper", "UnusedType.Local", Justification = "TrueType specification defined types")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Internal")] +[SuppressMessage( + "StyleCop.CSharp.NamingRules", + "SA1310:Field names should not contain underscore", + Justification = "Version name")] +[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Version name")] +internal static partial class TrueTypeUtils +{ + private readonly struct SfntFile : IReadOnlyDictionary> + { + // http://formats.kaitai.io/ttf/ttf.svg + + public static readonly TagStruct FileTagTrueType1 = new('1', '\0', '\0', '\0'); + public static readonly TagStruct FileTagType1 = new('t', 'y', 'p', '1'); + public static readonly TagStruct FileTagOpenTypeWithCff = new('O', 'T', 'T', 'O'); + public static readonly TagStruct FileTagOpenType1_0 = new('\0', '\x01', '\0', '\0'); + public static readonly TagStruct FileTagTrueTypeApple = new('t', 'r', 'u', 'e'); + + public readonly PointerSpan Memory; + public readonly int OffsetInCollection; + public readonly ushort TableCount; + + public SfntFile(PointerSpan memory, int offsetInCollection = 0) + { + var span = memory.Span; + this.Memory = memory; + this.OffsetInCollection = offsetInCollection; + this.TableCount = BinaryPrimitives.ReadUInt16BigEndian(span[4..]); + } + + public int Count => this.TableCount; + + public IEnumerable Keys => this.Select(x => x.Key); + + public IEnumerable> Values => this.Select(x => x.Value); + + public PointerSpan this[TagStruct key] => this.First(x => x.Key == key).Value; + + public IEnumerator>> GetEnumerator() + { + var offset = 12; + for (var i = 0; i < this.TableCount; i++) + { + var dte = new DirectoryTableEntry(this.Memory[offset..]); + yield return new(dte.Tag, this.Memory.Slice(dte.Offset - this.OffsetInCollection, dte.Length)); + + offset += Unsafe.SizeOf(); + } + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + public bool ContainsKey(TagStruct key) => this.Any(x => x.Key == key); + + public bool TryGetValue(TagStruct key, out PointerSpan value) + { + foreach (var (k, v) in this) + { + if (k == key) + { + value = v; + return true; + } + } + + value = default; + return false; + } + + public readonly struct DirectoryTableEntry + { + public readonly PointerSpan Memory; + + public DirectoryTableEntry(PointerSpan span) => this.Memory = span; + + public TagStruct Tag => new(this.Memory); + + public uint Checksum => this.Memory.ReadU32Big(4); + + public int Offset => this.Memory.ReadI32Big(8); + + public int Length => this.Memory.ReadI32Big(12); + } + } + + private readonly struct TtcFile : IReadOnlyList + { + public static readonly TagStruct FileTag = new('t', 't', 'c', 'f'); + + public readonly PointerSpan Memory; + public readonly TagStruct Tag; + public readonly ushort MajorVersion; + public readonly ushort MinorVersion; + public readonly int FontCount; + + public TtcFile(PointerSpan memory) + { + var span = memory.Span; + this.Memory = memory; + this.Tag = new(span); + if (this.Tag != FileTag) + throw new InvalidOperationException(); + + this.MajorVersion = BinaryPrimitives.ReadUInt16BigEndian(span[4..]); + this.MinorVersion = BinaryPrimitives.ReadUInt16BigEndian(span[6..]); + this.FontCount = BinaryPrimitives.ReadInt32BigEndian(span[8..]); + } + + public int Count => this.FontCount; + + public SfntFile this[int index] + { + get + { + if (index < 0 || index >= this.FontCount) + { + throw new IndexOutOfRangeException( + $"The requested font #{index} does not exist in this .ttc file."); + } + + var offset = BinaryPrimitives.ReadInt32BigEndian(this.Memory.Span[(12 + 4 * index)..]); + return new(this.Memory[offset..], offset); + } + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < this.FontCount; i++) + yield return this[i]; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.GposGsub.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.GposGsub.cs new file mode 100644 index 000000000..d200de47b --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.GposGsub.cs @@ -0,0 +1,259 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + [Flags] + private enum LookupFlags : byte + { + RightToLeft = 1 << 0, + IgnoreBaseGlyphs = 1 << 1, + IgnoreLigatures = 1 << 2, + IgnoreMarks = 1 << 3, + UseMarkFilteringSet = 1 << 4, + } + + private enum LookupType : ushort + { + SingleAdjustment = 1, + PairAdjustment = 2, + CursiveAttachment = 3, + MarkToBaseAttachment = 4, + MarkToLigatureAttachment = 5, + MarkToMarkAttachment = 6, + ContextPositioning = 7, + ChainedContextPositioning = 8, + ExtensionPositioning = 9, + } + + private readonly struct ClassDefTable + { + public readonly PointerSpan Memory; + + public ClassDefTable(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public Format1ClassArray Format1 => new(this.Memory); + + public Format2ClassRanges Format2 => new(this.Memory); + + public IEnumerable<(ushort Class, ushort GlyphId)> Enumerate() + { + switch (this.Format) + { + case 1: + { + var format1 = this.Format1; + var startId = format1.StartGlyphId; + var count = format1.GlyphCount; + var classes = format1.ClassValueArray; + for (var i = 0; i < count; i++) + yield return (classes[i], (ushort)(i + startId)); + + break; + } + + case 2: + { + foreach (var range in this.Format2.ClassValueArray) + { + var @class = range.Class; + var startId = range.StartGlyphId; + var count = range.EndGlyphId - startId + 1; + for (var i = 0; i < count; i++) + yield return (@class, (ushort)(startId + i)); + } + + break; + } + } + } + + [Pure] + public ushort GetClass(ushort glyphId) + { + switch (this.Format) + { + case 1: + { + var format1 = this.Format1; + var startId = format1.StartGlyphId; + if (startId <= glyphId && glyphId < startId + format1.GlyphCount) + return this.Format1.ClassValueArray[glyphId - startId]; + + break; + } + + case 2: + { + var rangeSpan = this.Format2.ClassValueArray; + var i = rangeSpan.BinarySearch(new Format2ClassRanges.ClassRangeRecord { EndGlyphId = glyphId }); + if (i >= 0 && rangeSpan[i].ContainsGlyph(glyphId)) + return rangeSpan[i].Class; + + break; + } + } + + return 0; + } + + public readonly struct Format1ClassArray + { + public readonly PointerSpan Memory; + + public Format1ClassArray(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort StartGlyphId => this.Memory.ReadU16Big(2); + + public ushort GlyphCount => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan ClassValueArray => new( + this.Memory[6..].As(this.GlyphCount), + BinaryPrimitives.ReverseEndianness); + } + + public readonly struct Format2ClassRanges + { + public readonly PointerSpan Memory; + + public Format2ClassRanges(PointerSpan memory) => this.Memory = memory; + + public ushort ClassRangeCount => this.Memory.ReadU16Big(2); + + public BigEndianPointerSpan ClassValueArray => new( + this.Memory[4..].As(this.ClassRangeCount), + ClassRangeRecord.ReverseEndianness); + + public struct ClassRangeRecord : IComparable + { + public ushort StartGlyphId; + public ushort EndGlyphId; + public ushort Class; + + public static ClassRangeRecord ReverseEndianness(ClassRangeRecord value) => new() + { + StartGlyphId = BinaryPrimitives.ReverseEndianness(value.StartGlyphId), + EndGlyphId = BinaryPrimitives.ReverseEndianness(value.EndGlyphId), + Class = BinaryPrimitives.ReverseEndianness(value.Class), + }; + + public int CompareTo(ClassRangeRecord other) => this.EndGlyphId.CompareTo(other.EndGlyphId); + + public bool ContainsGlyph(ushort glyphId) => + this.StartGlyphId <= glyphId && glyphId <= this.EndGlyphId; + } + } + } + + private readonly struct CoverageTable + { + public readonly PointerSpan Memory; + + public CoverageTable(PointerSpan memory) => this.Memory = memory; + + public enum CoverageFormat : ushort + { + Glyphs = 1, + RangeRecords = 2, + } + + public CoverageFormat Format => this.Memory.ReadEnumBig(0); + + public ushort Count => this.Memory.ReadU16Big(2); + + public BigEndianPointerSpan Glyphs => + this.Format == CoverageFormat.Glyphs + ? new(this.Memory[4..].As(this.Count), BinaryPrimitives.ReverseEndianness) + : default(BigEndianPointerSpan); + + public BigEndianPointerSpan RangeRecords => + this.Format == CoverageFormat.RangeRecords + ? new(this.Memory[4..].As(this.Count), RangeRecord.ReverseEndianness) + : default(BigEndianPointerSpan); + + public int GetCoverageIndex(ushort glyphId) + { + switch (this.Format) + { + case CoverageFormat.Glyphs: + return this.Glyphs.BinarySearch(glyphId); + + case CoverageFormat.RangeRecords: + { + var index = this.RangeRecords.BinarySearch( + (in RangeRecord record) => glyphId.CompareTo(record.EndGlyphId)); + + if (index >= 0 && this.RangeRecords[index].ContainsGlyph(glyphId)) + return index; + + return -1; + } + + default: + return -1; + } + } + + public struct RangeRecord + { + public ushort StartGlyphId; + public ushort EndGlyphId; + public ushort StartCoverageIndex; + + public static RangeRecord ReverseEndianness(RangeRecord value) => new() + { + StartGlyphId = BinaryPrimitives.ReverseEndianness(value.StartGlyphId), + EndGlyphId = BinaryPrimitives.ReverseEndianness(value.EndGlyphId), + StartCoverageIndex = BinaryPrimitives.ReverseEndianness(value.StartCoverageIndex), + }; + + public bool ContainsGlyph(ushort glyphId) => + this.StartGlyphId <= glyphId && glyphId <= this.EndGlyphId; + } + } + + private readonly struct LookupTable : IEnumerable> + { + public readonly PointerSpan Memory; + + public LookupTable(PointerSpan memory) => this.Memory = memory; + + public LookupType Type => this.Memory.ReadEnumBig(0); + + public byte MarkAttachmentType => this.Memory[2]; + + public LookupFlags Flags => (LookupFlags)this.Memory[3]; + + public ushort SubtableCount => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan SubtableOffsets => new( + this.Memory[6..].As(this.SubtableCount), + BinaryPrimitives.ReverseEndianness); + + public PointerSpan this[int index] => this.Memory[this.SubtableOffsets[this.EnsureIndex(index)] ..]; + + public IEnumerator> GetEnumerator() + { + foreach (var i in Enumerable.Range(0, this.SubtableCount)) + yield return this.Memory[this.SubtableOffsets[i] ..]; + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private int EnsureIndex(int index) => index >= 0 && index < this.SubtableCount + ? index + : throw new IndexOutOfRangeException(); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.PointerSpan.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.PointerSpan.cs new file mode 100644 index 000000000..c91df4ff2 --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.PointerSpan.cs @@ -0,0 +1,443 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Reactive.Disposables; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + private delegate int BinarySearchComparer(in T value); + + private static IDisposable CreatePointerSpan(this T[] data, out PointerSpan pointerSpan) + where T : unmanaged + { + var gchandle = GCHandle.Alloc(data, GCHandleType.Pinned); + pointerSpan = new(gchandle.AddrOfPinnedObject(), data.Length); + return Disposable.Create(() => gchandle.Free()); + } + + private static int BinarySearch(this IReadOnlyList span, in T value) + where T : unmanaged, IComparable + { + var l = 0; + var r = span.Count - 1; + while (l <= r) + { + var i = (int)(((uint)r + (uint)l) >> 1); + var c = value.CompareTo(span[i]); + switch (c) + { + case 0: + return i; + case > 0: + l = i + 1; + break; + default: + r = i - 1; + break; + } + } + + return ~l; + } + + private static int BinarySearch(this IReadOnlyList span, BinarySearchComparer comparer) + where T : unmanaged + { + var l = 0; + var r = span.Count - 1; + while (l <= r) + { + var i = (int)(((uint)r + (uint)l) >> 1); + var c = comparer(span[i]); + switch (c) + { + case 0: + return i; + case > 0: + l = i + 1; + break; + default: + r = i - 1; + break; + } + } + + return ~l; + } + + private static short ReadI16Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadInt16BigEndian(ps.Span[offset..]); + + private static int ReadI32Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadInt32BigEndian(ps.Span[offset..]); + + private static long ReadI64Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadInt64BigEndian(ps.Span[offset..]); + + private static ushort ReadU16Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadUInt16BigEndian(ps.Span[offset..]); + + private static uint ReadU32Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadUInt32BigEndian(ps.Span[offset..]); + + private static ulong ReadU64Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadUInt64BigEndian(ps.Span[offset..]); + + private static Half ReadF16Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadHalfBigEndian(ps.Span[offset..]); + + private static float ReadF32Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadSingleBigEndian(ps.Span[offset..]); + + private static double ReadF64Big(this PointerSpan ps, int offset) => + BinaryPrimitives.ReadDoubleBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out short value) => + value = BinaryPrimitives.ReadInt16BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out int value) => + value = BinaryPrimitives.ReadInt32BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out long value) => + value = BinaryPrimitives.ReadInt64BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out ushort value) => + value = BinaryPrimitives.ReadUInt16BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out uint value) => + value = BinaryPrimitives.ReadUInt32BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out ulong value) => + value = BinaryPrimitives.ReadUInt64BigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out Half value) => + value = BinaryPrimitives.ReadHalfBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out float value) => + value = BinaryPrimitives.ReadSingleBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, int offset, out double value) => + value = BinaryPrimitives.ReadDoubleBigEndian(ps.Span[offset..]); + + private static void ReadBig(this PointerSpan ps, ref int offset, out short value) + { + ps.ReadBig(offset, out value); + offset += 2; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out int value) + { + ps.ReadBig(offset, out value); + offset += 4; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out long value) + { + ps.ReadBig(offset, out value); + offset += 8; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out ushort value) + { + ps.ReadBig(offset, out value); + offset += 2; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out uint value) + { + ps.ReadBig(offset, out value); + offset += 4; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out ulong value) + { + ps.ReadBig(offset, out value); + offset += 8; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out Half value) + { + ps.ReadBig(offset, out value); + offset += 2; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out float value) + { + ps.ReadBig(offset, out value); + offset += 4; + } + + private static void ReadBig(this PointerSpan ps, ref int offset, out double value) + { + ps.ReadBig(offset, out value); + offset += 8; + } + + private static unsafe T ReadEnumBig(this PointerSpan ps, int offset) where T : unmanaged, Enum + { + switch (Marshal.SizeOf(Enum.GetUnderlyingType(typeof(T)))) + { + case 1: + var b1 = ps.Span[offset]; + return *(T*)&b1; + case 2: + var b2 = ps.ReadU16Big(offset); + return *(T*)&b2; + case 4: + var b4 = ps.ReadU32Big(offset); + return *(T*)&b4; + case 8: + var b8 = ps.ReadU64Big(offset); + return *(T*)&b8; + default: + throw new ArgumentException("Enum is not of size 1, 2, 4, or 8.", nameof(T), null); + } + } + + private static void ReadBig(this PointerSpan ps, int offset, out T value) where T : unmanaged, Enum => + value = ps.ReadEnumBig(offset); + + private static void ReadBig(this PointerSpan ps, ref int offset, out T value) where T : unmanaged, Enum + { + value = ps.ReadEnumBig(offset); + offset += Unsafe.SizeOf(); + } + + private readonly unsafe struct PointerSpan : IList, IReadOnlyList, ICollection + where T : unmanaged + { + public readonly T* Pointer; + + public PointerSpan(T* pointer, int count) + { + this.Pointer = pointer; + this.Count = count; + } + + public PointerSpan(nint pointer, int count) + : this((T*)pointer, count) + { + } + + public Span Span => new(this.Pointer, this.Count); + + public bool IsEmpty => this.Count == 0; + + public int Count { get; } + + public int Length => this.Count; + + public int ByteCount => sizeof(T) * this.Count; + + bool ICollection.IsSynchronized => false; + + object ICollection.SyncRoot => this; + + bool ICollection.IsReadOnly => false; + + public ref T this[int index] => ref this.Pointer[this.EnsureIndex(index)]; + + public PointerSpan this[Range range] => this.Slice(range.GetOffsetAndLength(this.Count)); + + T IList.this[int index] + { + get => this.Pointer[this.EnsureIndex(index)]; + set => this.Pointer[this.EnsureIndex(index)] = value; + } + + T IReadOnlyList.this[int index] => this.Pointer[this.EnsureIndex(index)]; + + public bool ContainsPointer(T2* obj) where T2 : unmanaged => + (T*)obj >= this.Pointer && (T*)(obj + 1) <= this.Pointer + this.Count; + + public PointerSpan Slice(int offset, int count) => new(this.Pointer + offset, count); + + public PointerSpan Slice((int Offset, int Count) offsetAndCount) + => this.Slice(offsetAndCount.Offset, offsetAndCount.Count); + + public PointerSpan As(int count) + where T2 : unmanaged => + count > this.Count / sizeof(T2) + ? throw new ArgumentOutOfRangeException( + nameof(count), + count, + $"Wanted {count} items; had {this.Count / sizeof(T2)} items") + : new((T2*)this.Pointer, count); + + public PointerSpan As() + where T2 : unmanaged => + new((T2*)this.Pointer, this.Count / sizeof(T2)); + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < this.Count; i++) + yield return this[i]; + } + + void ICollection.Add(T item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + bool ICollection.Contains(T item) + { + for (var i = 0; i < this.Count; i++) + { + if (Equals(this.Pointer[i], item)) + return true; + } + + return false; + } + + void ICollection.CopyTo(T[] array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array[arrayIndex + i] = this.Pointer[i]; + } + + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + int IList.IndexOf(T item) + { + for (var i = 0; i < this.Count; i++) + { + if (Equals(this.Pointer[i], item)) + return i; + } + + return -1; + } + + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + void ICollection.CopyTo(Array array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array.SetValue(this.Pointer[i], arrayIndex + i); + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private int EnsureIndex(int index) => + index >= 0 && index < this.Count ? index : throw new IndexOutOfRangeException(); + } + + private readonly unsafe struct BigEndianPointerSpan + : IList, IReadOnlyList, ICollection + where T : unmanaged + { + public readonly T* Pointer; + + private readonly Func reverseEndianness; + + public BigEndianPointerSpan(PointerSpan pointerSpan, Func reverseEndianness) + { + this.reverseEndianness = reverseEndianness; + this.Pointer = pointerSpan.Pointer; + this.Count = pointerSpan.Count; + } + + public int Count { get; } + + public int Length => this.Count; + + public int ByteCount => sizeof(T) * this.Count; + + public bool IsSynchronized => true; + + public object SyncRoot => this; + + public bool IsReadOnly => true; + + public T this[int index] + { + get => + BitConverter.IsLittleEndian + ? this.reverseEndianness(this.Pointer[this.EnsureIndex(index)]) + : this.Pointer[this.EnsureIndex(index)]; + set => this.Pointer[this.EnsureIndex(index)] = + BitConverter.IsLittleEndian + ? this.reverseEndianness(value) + : value; + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < this.Count; i++) + yield return this[i]; + } + + void ICollection.Add(T item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + bool ICollection.Contains(T item) => throw new NotSupportedException(); + + void ICollection.CopyTo(T[] array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array[arrayIndex + i] = this[i]; + } + + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + int IList.IndexOf(T item) + { + for (var i = 0; i < this.Count; i++) + { + if (Equals(this[i], item)) + return i; + } + + return -1; + } + + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + void ICollection.CopyTo(Array array, int arrayIndex) + { + if (array.Length < this.Count) + throw new ArgumentException(null, nameof(array)); + + if (array.Length < arrayIndex + this.Count) + throw new ArgumentException(null, nameof(arrayIndex)); + + for (var i = 0; i < this.Count; i++) + array.SetValue(this[i], arrayIndex + i); + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + private int EnsureIndex(int index) => + index >= 0 && index < this.Count ? index : throw new IndexOutOfRangeException(); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Tables.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Tables.cs new file mode 100644 index 000000000..80cf4b7da --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.Tables.cs @@ -0,0 +1,1391 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +[SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "TrueType specification defined fields")] +[SuppressMessage("ReSharper", "UnusedType.Local", Justification = "TrueType specification defined types")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Internal")] +internal static partial class TrueTypeUtils +{ + [Flags] + private enum ValueFormat : ushort + { + PlacementX = 1 << 0, + PlacementY = 1 << 1, + AdvanceX = 1 << 2, + AdvanceY = 1 << 3, + PlacementDeviceOffsetX = 1 << 4, + PlacementDeviceOffsetY = 1 << 5, + AdvanceDeviceOffsetX = 1 << 6, + AdvanceDeviceOffsetY = 1 << 7, + + ValidBits = 0 + | PlacementX | PlacementY + | AdvanceX | AdvanceY + | PlacementDeviceOffsetX | PlacementDeviceOffsetY + | AdvanceDeviceOffsetX | AdvanceDeviceOffsetY, + } + + private static int NumBytes(this ValueFormat value) => + ushort.PopCount((ushort)(value & ValueFormat.ValidBits)) * 2; + + private readonly struct Cmap + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/cmap + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6cmap.html + + public static readonly TagStruct DirectoryTableTag = new('c', 'm', 'a', 'p'); + + public readonly PointerSpan Memory; + + public Cmap(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Cmap(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort RecordCount => this.Memory.ReadU16Big(2); + + public BigEndianPointerSpan Records => new( + this.Memory[4..].As(this.RecordCount), + EncodingRecord.ReverseEndianness); + + public EncodingRecord? UnicodeEncodingRecord => + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Unicode, UnicodeEncoding: UnicodeEncodingId.Unicode_2_0_Bmp }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Unicode, UnicodeEncoding: UnicodeEncodingId.Unicode_2_0_Full }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Unicode, UnicodeEncoding: UnicodeEncodingId.UnicodeFullRepertoire }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Windows, WindowsEncoding: WindowsEncodingId.UnicodeBmp }) + ?? + this.Records.Select(x => (EncodingRecord?)x).FirstOrDefault( + x => x!.Value.PlatformAndEncoding is + { Platform: PlatformId.Windows, WindowsEncoding: WindowsEncodingId.UnicodeFullRepertoire }); + + public CmapFormat? UnicodeTable => this.GetTable(this.UnicodeEncodingRecord); + + public CmapFormat? GetTable(EncodingRecord? encodingRecord) => + encodingRecord is { } record + ? this.Memory.ReadU16Big(record.SubtableOffset) switch + { + 0 => new CmapFormat0(this.Memory[record.SubtableOffset..]), + 2 => new CmapFormat2(this.Memory[record.SubtableOffset..]), + 4 => new CmapFormat4(this.Memory[record.SubtableOffset..]), + 6 => new CmapFormat6(this.Memory[record.SubtableOffset..]), + 8 => new CmapFormat8(this.Memory[record.SubtableOffset..]), + 10 => new CmapFormat10(this.Memory[record.SubtableOffset..]), + 12 or 13 => new CmapFormat12And13(this.Memory[record.SubtableOffset..]), + _ => null, + } + : null; + + public struct EncodingRecord + { + public PlatformAndEncoding PlatformAndEncoding; + public int SubtableOffset; + + public EncodingRecord(PointerSpan span) + { + this.PlatformAndEncoding = new(span); + var offset = Unsafe.SizeOf(); + span.ReadBig(ref offset, out this.SubtableOffset); + } + + public static EncodingRecord ReverseEndianness(EncodingRecord value) => new() + { + PlatformAndEncoding = PlatformAndEncoding.ReverseEndianness(value.PlatformAndEncoding), + SubtableOffset = BinaryPrimitives.ReverseEndianness(value.SubtableOffset), + }; + } + + public struct MapGroup : IComparable + { + public int StartCharCode; + public int EndCharCode; + public int GlyphId; + + public MapGroup(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.StartCharCode); + span.ReadBig(ref offset, out this.EndCharCode); + span.ReadBig(ref offset, out this.GlyphId); + } + + public static MapGroup ReverseEndianness(MapGroup obj) => new() + { + StartCharCode = BinaryPrimitives.ReverseEndianness(obj.StartCharCode), + EndCharCode = BinaryPrimitives.ReverseEndianness(obj.EndCharCode), + GlyphId = BinaryPrimitives.ReverseEndianness(obj.GlyphId), + }; + + public int CompareTo(MapGroup other) + { + var endCharCodeComparison = this.EndCharCode.CompareTo(other.EndCharCode); + if (endCharCodeComparison != 0) return endCharCodeComparison; + + var startCharCodeComparison = this.StartCharCode.CompareTo(other.StartCharCode); + if (startCharCodeComparison != 0) return startCharCodeComparison; + + return this.GlyphId.CompareTo(other.GlyphId); + } + } + + public abstract class CmapFormat : IReadOnlyDictionary + { + public int Count => this.Count(x => x.Value != 0); + + public IEnumerable Keys => this.Select(x => x.Key); + + public IEnumerable Values => this.Select(x => x.Value); + + public ushort this[int key] => throw new NotImplementedException(); + + public abstract ushort CharToGlyph(int c); + + public abstract IEnumerator> GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + public bool ContainsKey(int key) => this.CharToGlyph(key) != 0; + + public bool TryGetValue(int key, out ushort value) + { + value = this.CharToGlyph(key); + return value != 0; + } + } + + public class CmapFormat0 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat0(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public PointerSpan GlyphIdArray => this.Memory.Slice(6, 256); + + public override ushort CharToGlyph(int c) => c is >= 0 and < 256 ? this.GlyphIdArray[c] : (byte)0; + + public override IEnumerator> GetEnumerator() + { + for (var codepoint = 0; codepoint < 256; codepoint++) + { + if (this.GlyphIdArray[codepoint] is var glyphId and not 0) + yield return new(codepoint, glyphId); + } + } + } + + public class CmapFormat2 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat2(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan SubHeaderKeys => new( + this.Memory[6..].As(256), + BinaryPrimitives.ReverseEndianness); + + public PointerSpan Data => this.Memory[518..]; + + public bool TryGetSubHeader( + int keyIndex, out SubHeader subheader, out BigEndianPointerSpan glyphSpan) + { + if (keyIndex < 0 || keyIndex >= this.SubHeaderKeys.Count) + { + subheader = default; + glyphSpan = default; + return false; + } + + var offset = this.SubHeaderKeys[keyIndex]; + if (offset + Unsafe.SizeOf() > this.Data.Length) + { + subheader = default; + glyphSpan = default; + return false; + } + + subheader = new(this.Data[offset..]); + glyphSpan = new( + this.Data[(offset + Unsafe.SizeOf() + subheader.IdRangeOffset)..] + .As(subheader.EntryCount), + BinaryPrimitives.ReverseEndianness); + + return true; + } + + public override ushort CharToGlyph(int c) + { + if (!this.TryGetSubHeader(c >> 8, out var sh, out var glyphSpan)) + return 0; + + c = (c & 0xFF) - sh.FirstCode; + if (c > 0 || c >= glyphSpan.Count) + return 0; + + var res = glyphSpan[c]; + return res == 0 ? (ushort)0 : unchecked((ushort)(res + sh.IdDelta)); + } + + public override IEnumerator> GetEnumerator() + { + for (var i = 0; i < this.SubHeaderKeys.Count; i++) + { + if (!this.TryGetSubHeader(i, out var sh, out var glyphSpan)) + continue; + + for (var j = 0; j < glyphSpan.Count; j++) + { + var res = glyphSpan[j]; + if (res == 0) + continue; + + var glyphId = unchecked((ushort)(res + sh.IdDelta)); + if (glyphId == 0) + continue; + + var codepoint = (i << 8) | (sh.FirstCode + j); + yield return new(codepoint, glyphId); + } + } + } + + public struct SubHeader + { + public ushort FirstCode; + public ushort EntryCount; + public ushort IdDelta; + public ushort IdRangeOffset; + + public SubHeader(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.FirstCode); + span.ReadBig(ref offset, out this.EntryCount); + span.ReadBig(ref offset, out this.IdDelta); + span.ReadBig(ref offset, out this.IdRangeOffset); + } + } + } + + public class CmapFormat4 : CmapFormat + { + public const int EndCodesOffset = 14; + + public readonly PointerSpan Memory; + + public CmapFormat4(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public ushort SegCountX2 => this.Memory.ReadU16Big(6); + + public ushort SearchRange => this.Memory.ReadU16Big(8); + + public ushort EntrySelector => this.Memory.ReadU16Big(10); + + public ushort RangeShift => this.Memory.ReadU16Big(12); + + public BigEndianPointerSpan EndCodes => new( + this.Memory.Slice(EndCodesOffset, this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan StartCodes => new( + this.Memory.Slice(EndCodesOffset + 2 + (1 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan IdDeltas => new( + this.Memory.Slice(EndCodesOffset + 2 + (2 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan IdRangeOffsets => new( + this.Memory.Slice(EndCodesOffset + 2 + (3 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public BigEndianPointerSpan GlyphIds => new( + this.Memory.Slice(EndCodesOffset + 2 + (4 * this.SegCountX2), this.SegCountX2).As(), + BinaryPrimitives.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + if (c is < 0 or >= 0x10000) + return 0; + + var i = this.EndCodes.BinarySearch((ushort)c); + if (i < 0) + return 0; + + var startCode = this.StartCodes[i]; + var endCode = this.EndCodes[i]; + if (c < startCode || c > endCode) + return 0; + + var idRangeOffset = this.IdRangeOffsets[i]; + var idDelta = this.IdDeltas[i]; + if (idRangeOffset == 0) + return unchecked((ushort)(c + idDelta)); + + var ptr = EndCodesOffset + 2 + (3 * this.SegCountX2) + i * 2 + idRangeOffset; + if (ptr > this.Memory.Length) + return 0; + + var glyphs = new BigEndianPointerSpan( + this.Memory[ptr..].As(endCode - startCode + 1), + BinaryPrimitives.ReverseEndianness); + + var glyph = glyphs[c - startCode]; + return unchecked(glyph == 0 ? (ushort)0 : (ushort)(idDelta + glyph)); + } + + public override IEnumerator> GetEnumerator() + { + var startCodes = this.StartCodes; + var endCodes = this.EndCodes; + var idDeltas = this.IdDeltas; + var idRangeOffsets = this.IdRangeOffsets; + + for (var i = 0; i < this.SegCountX2 / 2; i++) + { + var startCode = startCodes[i]; + var endCode = endCodes[i]; + var idRangeOffset = idRangeOffsets[i]; + var idDelta = idDeltas[i]; + + if (idRangeOffset == 0) + { + for (var c = (int)startCode; c <= endCode; c++) + yield return new(c, (ushort)(c + idDelta)); + } + else + { + var ptr = EndCodesOffset + 2 + (3 * this.SegCountX2) + i * 2 + idRangeOffset; + if (ptr >= this.Memory.Length) + continue; + + var glyphs = new BigEndianPointerSpan( + this.Memory[ptr..].As(endCode - startCode + 1), + BinaryPrimitives.ReverseEndianness); + + for (var j = 0; j < glyphs.Count; j++) + { + var glyphId = glyphs[j]; + if (glyphId == 0) + continue; + + glyphId += idDelta; + if (glyphId == 0) + continue; + + yield return new(startCode + j, glyphId); + } + } + } + } + } + + public class CmapFormat6 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat6(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public ushort Language => this.Memory.ReadU16Big(4); + + public ushort FirstCode => this.Memory.ReadU16Big(6); + + public ushort EntryCount => this.Memory.ReadU16Big(8); + + public BigEndianPointerSpan GlyphIds => new( + this.Memory[10..].As(this.EntryCount), + BinaryPrimitives.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + var glyphIds = this.GlyphIds; + if (c < this.FirstCode || c >= this.FirstCode + this.GlyphIds.Count) + return 0; + + return glyphIds[c - this.FirstCode]; + } + + public override IEnumerator> GetEnumerator() + { + var glyphIds = this.GlyphIds; + for (var i = 0; i < this.GlyphIds.Length; i++) + { + var g = glyphIds[i]; + if (g != 0) + yield return new(this.FirstCode + i, g); + } + } + } + + public class CmapFormat8 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat8(PointerSpan memory) => this.Memory = memory; + + public int Format => this.Memory.ReadI32Big(0); + + public int Length => this.Memory.ReadI32Big(4); + + public int Language => this.Memory.ReadI32Big(8); + + public PointerSpan Is32 => this.Memory.Slice(12, 8192); + + public int NumGroups => this.Memory.ReadI32Big(8204); + + public BigEndianPointerSpan Groups => + new(this.Memory[8208..].As(), MapGroup.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + var groups = this.Groups; + + var i = groups.BinarySearch((in MapGroup value) => c.CompareTo(value.EndCharCode)); + if (i < 0) + return 0; + + var group = groups[i]; + if (c < group.StartCharCode || c > group.EndCharCode) + return 0; + + return unchecked((ushort)(group.GlyphId + c - group.StartCharCode)); + } + + public override IEnumerator> GetEnumerator() + { + foreach (var group in this.Groups) + { + for (var j = group.StartCharCode; j <= group.EndCharCode; j++) + { + var glyphId = (ushort)(group.GlyphId + j - group.StartCharCode); + if (glyphId == 0) + continue; + + yield return new(j, glyphId); + } + } + } + } + + public class CmapFormat10 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat10(PointerSpan memory) => this.Memory = memory; + + public int Format => this.Memory.ReadI32Big(0); + + public int Length => this.Memory.ReadI32Big(4); + + public int Language => this.Memory.ReadI32Big(8); + + public int StartCharCode => this.Memory.ReadI32Big(12); + + public int NumChars => this.Memory.ReadI32Big(16); + + public BigEndianPointerSpan GlyphIdArray => new( + this.Memory.Slice(20, this.NumChars * 2).As(), + BinaryPrimitives.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + if (c < this.StartCharCode || c >= this.StartCharCode + this.GlyphIdArray.Count) + return 0; + + return this.GlyphIdArray[c]; + } + + public override IEnumerator> GetEnumerator() + { + for (var i = 0; i < this.GlyphIdArray.Count; i++) + { + var glyph = this.GlyphIdArray[i]; + if (glyph != 0) + yield return new(this.StartCharCode + i, glyph); + } + } + } + + public class CmapFormat12And13 : CmapFormat + { + public readonly PointerSpan Memory; + + public CmapFormat12And13(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public int Length => this.Memory.ReadI32Big(4); + + public int Language => this.Memory.ReadI32Big(8); + + public int NumGroups => this.Memory.ReadI32Big(12); + + public BigEndianPointerSpan Groups => new( + this.Memory[16..].As(this.NumGroups), + MapGroup.ReverseEndianness); + + public override ushort CharToGlyph(int c) + { + var groups = this.Groups; + + var i = groups.BinarySearch(new MapGroup() { EndCharCode = c }); + if (i < 0) + return 0; + + var group = groups[i]; + if (c < group.StartCharCode || c > group.EndCharCode) + return 0; + + if (this.Format == 12) + return (ushort)(group.GlyphId + c - group.StartCharCode); + else + return (ushort)group.GlyphId; + } + + public override IEnumerator> GetEnumerator() + { + var groups = this.Groups; + if (this.Format == 12) + { + foreach (var group in groups) + { + for (var j = group.StartCharCode; j <= group.EndCharCode; j++) + { + var glyphId = (ushort)(group.GlyphId + j - group.StartCharCode); + if (glyphId == 0) + continue; + + yield return new(j, glyphId); + } + } + } + else + { + foreach (var group in groups) + { + if (group.GlyphId == 0) + continue; + + for (var j = group.StartCharCode; j <= group.EndCharCode; j++) + yield return new(j, (ushort)group.GlyphId); + } + } + } + } + } + + private readonly struct Gpos + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos + + public static readonly TagStruct DirectoryTableTag = new('G', 'P', 'O', 'S'); + + public readonly PointerSpan Memory; + + public Gpos(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Gpos(PointerSpan memory) => this.Memory = memory; + + public Fixed Version => new(this.Memory); + + public ushort ScriptListOffset => this.Memory.ReadU16Big(4); + + public ushort FeatureListOffset => this.Memory.ReadU16Big(6); + + public ushort LookupListOffset => this.Memory.ReadU16Big(8); + + public uint FeatureVariationsOffset => this.Version.CompareTo(new(1, 1)) >= 0 + ? this.Memory.ReadU32Big(10) + : 0; + + public BigEndianPointerSpan LookupOffsetList => new( + this.Memory[(this.LookupListOffset + 2)..].As( + this.Memory.ReadU16Big(this.LookupListOffset)), + BinaryPrimitives.ReverseEndianness); + + public IEnumerable EnumerateLookupTables() + { + foreach (var offset in this.LookupOffsetList) + yield return new(this.Memory[(this.LookupListOffset + offset)..]); + } + + public IEnumerable ExtractAdvanceX() => + this.EnumerateLookupTables() + .SelectMany( + lookupTable => lookupTable.Type switch + { + LookupType.PairAdjustment => + lookupTable.SelectMany(y => new PairAdjustmentPositioning(y).ExtractAdvanceX()), + LookupType.ExtensionPositioning => + lookupTable + .Where(y => y.ReadU16Big(0) == 1) + .Select(y => new ExtensionPositioningSubtableFormat1(y)) + .Where(y => y.ExtensionLookupType == LookupType.PairAdjustment) + .SelectMany(y => new PairAdjustmentPositioning(y.ExtensionData).ExtractAdvanceX()), + _ => Array.Empty(), + }); + + public struct ValueRecord + { + public short PlacementX; + public short PlacementY; + public short AdvanceX; + public short AdvanceY; + public short PlacementDeviceOffsetX; + public short PlacementDeviceOffsetY; + public short AdvanceDeviceOffsetX; + public short AdvanceDeviceOffsetY; + + public ValueRecord(PointerSpan pointerSpan, ValueFormat valueFormat) + { + var offset = 0; + if ((valueFormat & ValueFormat.PlacementX) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementX); + + if ((valueFormat & ValueFormat.PlacementY) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementY); + + if ((valueFormat & ValueFormat.AdvanceX) != 0) pointerSpan.ReadBig(ref offset, out this.AdvanceX); + if ((valueFormat & ValueFormat.AdvanceY) != 0) pointerSpan.ReadBig(ref offset, out this.AdvanceY); + if ((valueFormat & ValueFormat.PlacementDeviceOffsetX) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementDeviceOffsetX); + + if ((valueFormat & ValueFormat.PlacementDeviceOffsetY) != 0) + pointerSpan.ReadBig(ref offset, out this.PlacementDeviceOffsetY); + + if ((valueFormat & ValueFormat.AdvanceDeviceOffsetX) != 0) + pointerSpan.ReadBig(ref offset, out this.AdvanceDeviceOffsetX); + + if ((valueFormat & ValueFormat.AdvanceDeviceOffsetY) != 0) + pointerSpan.ReadBig(ref offset, out this.AdvanceDeviceOffsetY); + } + } + + public readonly struct PairAdjustmentPositioning + { + public readonly PointerSpan Memory; + + public PairAdjustmentPositioning(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public IEnumerable ExtractAdvanceX() => this.Format switch + { + 1 => new Format1(this.Memory).ExtractAdvanceX(), + 2 => new Format2(this.Memory).ExtractAdvanceX(), + _ => Array.Empty(), + }; + + public readonly struct Format1 + { + public readonly PointerSpan Memory; + + public Format1(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort CoverageOffset => this.Memory.ReadU16Big(2); + + public ValueFormat ValueFormat1 => this.Memory.ReadEnumBig(4); + + public ValueFormat ValueFormat2 => this.Memory.ReadEnumBig(6); + + public ushort PairSetCount => this.Memory.ReadU16Big(8); + + public BigEndianPointerSpan PairSetOffsets => new( + this.Memory[10..].As(this.PairSetCount), + BinaryPrimitives.ReverseEndianness); + + public CoverageTable CoverageTable => new(this.Memory[this.CoverageOffset..]); + + public PairSet this[int index] => new( + this.Memory[this.PairSetOffsets[index] ..], + this.ValueFormat1, + this.ValueFormat2); + + public IEnumerable ExtractAdvanceX() + { + if ((this.ValueFormat1 & ValueFormat.AdvanceX) == 0 && + (this.ValueFormat2 & ValueFormat.AdvanceX) == 0) + { + yield break; + } + + var coverageTable = this.CoverageTable; + switch (coverageTable.Format) + { + case CoverageTable.CoverageFormat.Glyphs: + { + var glyphSpan = coverageTable.Glyphs; + foreach (var coverageIndex in Enumerable.Range(0, glyphSpan.Count)) + { + var glyph1Id = glyphSpan[coverageIndex]; + PairSet pairSetView; + try + { + pairSetView = this[coverageIndex]; + } + catch (ArgumentOutOfRangeException) + { + yield break; + } + catch (IndexOutOfRangeException) + { + yield break; + } + + foreach (var pairIndex in Enumerable.Range(0, pairSetView.Count)) + { + var pair = pairSetView[pairIndex]; + var adj = (short)(pair.Record1.AdvanceX + pair.Record2.PlacementX); + if (adj >= 10000) + System.Diagnostics.Debugger.Break(); + + if (adj != 0) + yield return new(glyph1Id, pair.SecondGlyph, adj); + } + } + + break; + } + + case CoverageTable.CoverageFormat.RangeRecords: + { + foreach (var rangeRecord in coverageTable.RangeRecords) + { + var startGlyphId = rangeRecord.StartGlyphId; + var endGlyphId = rangeRecord.EndGlyphId; + var startCoverageIndex = rangeRecord.StartCoverageIndex; + var glyphCount = endGlyphId - startGlyphId + 1; + foreach (var glyph1Id in Enumerable.Range(startGlyphId, glyphCount)) + { + PairSet pairSetView; + try + { + pairSetView = this[startCoverageIndex + glyph1Id - startGlyphId]; + } + catch (ArgumentOutOfRangeException) + { + yield break; + } + catch (IndexOutOfRangeException) + { + yield break; + } + + foreach (var pairIndex in Enumerable.Range(0, pairSetView.Count)) + { + var pair = pairSetView[pairIndex]; + var adj = (short)(pair.Record1.AdvanceX + pair.Record2.PlacementX); + if (adj != 0) + yield return new((ushort)glyph1Id, pair.SecondGlyph, adj); + } + } + } + + break; + } + } + } + + public readonly struct PairSet + { + public readonly PointerSpan Memory; + public readonly ValueFormat ValueFormat1; + public readonly ValueFormat ValueFormat2; + public readonly int PairValue1Size; + public readonly int PairValue2Size; + public readonly int PairSize; + + public PairSet( + PointerSpan memory, + ValueFormat valueFormat1, + ValueFormat valueFormat2) + { + this.Memory = memory; + this.ValueFormat1 = valueFormat1; + this.ValueFormat2 = valueFormat2; + this.PairValue1Size = this.ValueFormat1.NumBytes(); + this.PairValue2Size = this.ValueFormat2.NumBytes(); + this.PairSize = 2 + this.PairValue1Size + this.PairValue2Size; + } + + public ushort Count => this.Memory.ReadU16Big(0); + + public PairValueRecord this[int index] + { + get + { + var pvr = this.Memory.Slice(2 + (this.PairSize * index), this.PairSize); + return new() + { + SecondGlyph = pvr.ReadU16Big(0), + Record1 = new(pvr.Slice(2, this.PairValue1Size), this.ValueFormat1), + Record2 = new( + pvr.Slice(2 + this.PairValue1Size, this.PairValue2Size), + this.ValueFormat2), + }; + } + } + + public struct PairValueRecord + { + public ushort SecondGlyph; + public ValueRecord Record1; + public ValueRecord Record2; + } + } + } + + public readonly struct Format2 + { + public readonly PointerSpan Memory; + public readonly int PairValue1Size; + public readonly int PairValue2Size; + public readonly int PairSize; + + public Format2(PointerSpan memory) + { + this.Memory = memory; + this.PairValue1Size = this.ValueFormat1.NumBytes(); + this.PairValue2Size = this.ValueFormat2.NumBytes(); + this.PairSize = this.PairValue1Size + this.PairValue2Size; + } + + public ushort Format => this.Memory.ReadU16Big(0); + + public ushort CoverageOffset => this.Memory.ReadU16Big(2); + + public ValueFormat ValueFormat1 => this.Memory.ReadEnumBig(4); + + public ValueFormat ValueFormat2 => this.Memory.ReadEnumBig(6); + + public ushort ClassDef1Offset => this.Memory.ReadU16Big(8); + + public ushort ClassDef2Offset => this.Memory.ReadU16Big(10); + + public ushort Class1Count => this.Memory.ReadU16Big(12); + + public ushort Class2Count => this.Memory.ReadU16Big(14); + + public ClassDefTable ClassDefTable1 => new(this.Memory[this.ClassDef1Offset..]); + + public ClassDefTable ClassDefTable2 => new(this.Memory[this.ClassDef2Offset..]); + + public (ValueRecord Record1, ValueRecord Record2) this[(int Class1Index, int Class2Index) v] => + this[v.Class1Index, v.Class2Index]; + + public (ValueRecord Record1, ValueRecord Record2) this[int class1Index, int class2Index] + { + get + { + if (class1Index < 0 || class1Index >= this.Class1Count) + throw new IndexOutOfRangeException(); + + if (class2Index < 0 || class2Index >= this.Class2Count) + throw new IndexOutOfRangeException(); + + var offset = 16 + (this.PairSize * ((class1Index * this.Class2Count) + class2Index)); + return ( + new(this.Memory.Slice(offset, this.PairValue1Size), this.ValueFormat1), + new( + this.Memory.Slice(offset + this.PairValue1Size, this.PairValue2Size), + this.ValueFormat2)); + } + } + + public IEnumerable ExtractAdvanceX() + { + if ((this.ValueFormat1 & ValueFormat.AdvanceX) == 0 && + (this.ValueFormat2 & ValueFormat.AdvanceX) == 0) + { + yield break; + } + + var classes1 = this.ClassDefTable1.Enumerate() + .GroupBy(x => x.Class, x => x.GlyphId) + .ToImmutableDictionary(x => x.Key, x => x.ToImmutableSortedSet()); + + var classes2 = this.ClassDefTable2.Enumerate() + .GroupBy(x => x.Class, x => x.GlyphId) + .ToImmutableDictionary(x => x.Key, x => x.ToImmutableSortedSet()); + + foreach (var class1 in Enumerable.Range(0, this.Class1Count)) + { + if (!classes1.TryGetValue((ushort)class1, out var glyphs1)) + continue; + + foreach (var class2 in Enumerable.Range(0, this.Class2Count)) + { + if (!classes2.TryGetValue((ushort)class2, out var glyphs2)) + continue; + + (ValueRecord, ValueRecord) record; + try + { + record = this[class1, class2]; + } + catch (ArgumentOutOfRangeException) + { + yield break; + } + catch (IndexOutOfRangeException) + { + yield break; + } + + var val = record.Item1.AdvanceX + record.Item2.PlacementX; + if (val == 0) + continue; + + foreach (var glyph1 in glyphs1) + { + foreach (var glyph2 in glyphs2) + { + yield return new(glyph1, glyph2, (short)val); + } + } + } + } + } + } + } + + public readonly struct ExtensionPositioningSubtableFormat1 + { + public readonly PointerSpan Memory; + + public ExtensionPositioningSubtableFormat1(PointerSpan memory) => this.Memory = memory; + + public ushort Format => this.Memory.ReadU16Big(0); + + public LookupType ExtensionLookupType => this.Memory.ReadEnumBig(2); + + public int ExtensionOffset => this.Memory.ReadI32Big(4); + + public PointerSpan ExtensionData => this.Memory[this.ExtensionOffset..]; + } + } + + private readonly struct Head + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/head + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6head.html + + public const uint MagicNumberValue = 0x5F0F3CF5; + public static readonly TagStruct DirectoryTableTag = new('h', 'e', 'a', 'd'); + + public readonly PointerSpan Memory; + + public Head(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Head(PointerSpan memory) => this.Memory = memory; + + [Flags] + public enum HeadFlags : ushort + { + BaselineForFontAtZeroY = 1 << 0, + LeftSideBearingAtZeroX = 1 << 1, + InstructionsDependOnPointSize = 1 << 2, + ForcePpemsInteger = 1 << 3, + InstructionsAlterAdvanceWidth = 1 << 4, + VerticalLayout = 1 << 5, + Reserved6 = 1 << 6, + RequiresLayoutForCorrectLinguisticRendering = 1 << 7, + IsAatFont = 1 << 8, + ContainsRtlGlyph = 1 << 9, + ContainsIndicStyleRearrangementEffects = 1 << 10, + Lossless = 1 << 11, + ProduceCompatibleMetrics = 1 << 12, + OptimizedForClearType = 1 << 13, + IsLastResortFont = 1 << 14, + Reserved15 = 1 << 15, + } + + [Flags] + public enum MacStyleFlags : ushort + { + Bold = 1 << 0, + Italic = 1 << 1, + Underline = 1 << 2, + Outline = 1 << 3, + Shadow = 1 << 4, + Condensed = 1 << 5, + Extended = 1 << 6, + } + + public Fixed Version => new(this.Memory); + + public Fixed FontRevision => new(this.Memory[4..]); + + public uint ChecksumAdjustment => this.Memory.ReadU32Big(8); + + public uint MagicNumber => this.Memory.ReadU32Big(12); + + public HeadFlags Flags => this.Memory.ReadEnumBig(16); + + public ushort UnitsPerEm => this.Memory.ReadU16Big(18); + + public ulong CreatedTimestamp => this.Memory.ReadU64Big(20); + + public ulong ModifiedTimestamp => this.Memory.ReadU64Big(28); + + public ushort MinX => this.Memory.ReadU16Big(36); + + public ushort MinY => this.Memory.ReadU16Big(38); + + public ushort MaxX => this.Memory.ReadU16Big(40); + + public ushort MaxY => this.Memory.ReadU16Big(42); + + public MacStyleFlags MacStyle => this.Memory.ReadEnumBig(44); + + public ushort LowestRecommendedPpem => this.Memory.ReadU16Big(46); + + public ushort FontDirectionHint => this.Memory.ReadU16Big(48); + + public ushort IndexToLocFormat => this.Memory.ReadU16Big(50); + + public ushort GlyphDataFormat => this.Memory.ReadU16Big(52); + } + + private readonly struct Kern + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/kern + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6kern.html + + public static readonly TagStruct DirectoryTableTag = new('k', 'e', 'r', 'n'); + + public readonly PointerSpan Memory; + + public Kern(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Kern(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public IEnumerable EnumerateHorizontalPairs() => this.Version switch + { + 0 => new Version0(this.Memory).EnumerateHorizontalPairs(), + 1 => new Version1(this.Memory).EnumerateHorizontalPairs(), + _ => Array.Empty(), + }; + + public readonly struct Format0 + { + public readonly PointerSpan Memory; + + public Format0(PointerSpan memory) => this.Memory = memory; + + public ushort PairCount => this.Memory.ReadU16Big(0); + + public ushort SearchRange => this.Memory.ReadU16Big(2); + + public ushort EntrySelector => this.Memory.ReadU16Big(4); + + public ushort RangeShift => this.Memory.ReadU16Big(6); + + public BigEndianPointerSpan Pairs => new( + this.Memory[8..].As(this.PairCount), + KerningPair.ReverseEndianness); + } + + public readonly struct Version0 + { + public readonly PointerSpan Memory; + + public Version0(PointerSpan memory) => this.Memory = memory; + + [Flags] + public enum CoverageFlags : byte + { + Horizontal = 1 << 0, + Minimum = 1 << 1, + CrossStream = 1 << 2, + Override = 1 << 3, + } + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort NumSubtables => this.Memory.ReadU16Big(2); + + public PointerSpan Data => this.Memory[4..]; + + public IEnumerable EnumerateSubtables() + { + var data = this.Data; + for (var i = 0; i < this.NumSubtables && !data.IsEmpty; i++) + { + var st = new Subtable(data); + data = data[st.Length..]; + yield return st; + } + } + + public IEnumerable EnumerateHorizontalPairs() + { + var accumulator = new Dictionary<(ushort Left, ushort Right), short>(); + foreach (var subtable in this.EnumerateSubtables()) + { + var isOverride = (subtable.Flags & CoverageFlags.Override) != 0; + var isMinimum = (subtable.Flags & CoverageFlags.Minimum) != 0; + foreach (var t in subtable.EnumeratePairs()) + { + if (isOverride) + { + accumulator[(t.Left, t.Right)] = t.Value; + } + else if (isMinimum) + { + accumulator[(t.Left, t.Right)] = Math.Max( + accumulator.GetValueOrDefault((t.Left, t.Right), t.Value), + t.Value); + } + else + { + accumulator[(t.Left, t.Right)] = (short)( + accumulator.GetValueOrDefault( + (t.Left, t.Right)) + t.Value); + } + } + } + + return accumulator.Select( + x => new KerningPair { Left = x.Key.Left, Right = x.Key.Right, Value = x.Value }); + } + + public readonly struct Subtable + { + public readonly PointerSpan Memory; + + public Subtable(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort Length => this.Memory.ReadU16Big(2); + + public byte Format => this.Memory[4]; + + public CoverageFlags Flags => this.Memory.ReadEnumBig(5); + + public PointerSpan Data => this.Memory[6..]; + + public IEnumerable EnumeratePairs() => this.Format switch + { + 0 => new Format0(this.Data).Pairs, + _ => Array.Empty(), + }; + } + } + + public readonly struct Version1 + { + public readonly PointerSpan Memory; + + public Version1(PointerSpan memory) => this.Memory = memory; + + [Flags] + public enum CoverageFlags : byte + { + Vertical = 1 << 0, + CrossStream = 1 << 1, + Variation = 1 << 2, + } + + public Fixed Version => new(this.Memory); + + public int NumSubtables => this.Memory.ReadI16Big(4); + + public PointerSpan Data => this.Memory[8..]; + + public IEnumerable EnumerateSubtables() + { + var data = this.Data; + for (var i = 0; i < this.NumSubtables && !data.IsEmpty; i++) + { + var st = new Subtable(data); + data = data[st.Length..]; + yield return st; + } + } + + public IEnumerable EnumerateHorizontalPairs() => this + .EnumerateSubtables() + .Where(x => x.Flags == 0) + .SelectMany(x => x.EnumeratePairs()); + + public readonly struct Subtable + { + public readonly PointerSpan Memory; + + public Subtable(PointerSpan memory) => this.Memory = memory; + + public int Length => this.Memory.ReadI32Big(0); + + public byte Format => this.Memory[4]; + + public CoverageFlags Flags => this.Memory.ReadEnumBig(5); + + public ushort TupleIndex => this.Memory.ReadU16Big(6); + + public PointerSpan Data => this.Memory[8..]; + + public IEnumerable EnumeratePairs() => this.Format switch + { + 0 => new Format0(this.Data).Pairs, + _ => Array.Empty(), + }; + } + } + } + + private readonly struct Name + { + // https://docs.microsoft.com/en-us/typography/opentype/spec/name + // https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6name.html + + public static readonly TagStruct DirectoryTableTag = new('n', 'a', 'm', 'e'); + + public readonly PointerSpan Memory; + + public Name(SfntFile file) + : this(file[DirectoryTableTag]) + { + } + + public Name(PointerSpan memory) => this.Memory = memory; + + public ushort Version => this.Memory.ReadU16Big(0); + + public ushort Count => this.Memory.ReadU16Big(2); + + public ushort StorageOffset => this.Memory.ReadU16Big(4); + + public BigEndianPointerSpan NameRecords => new( + this.Memory[6..].As(this.Count), + NameRecord.ReverseEndianness); + + public ushort LanguageCount => + this.Version == 0 ? (ushort)0 : this.Memory.ReadU16Big(6 + this.NameRecords.ByteCount); + + public BigEndianPointerSpan LanguageRecords => this.Version == 0 + ? default + : new( + this.Memory[ + (8 + this.NameRecords + .ByteCount)..] + .As( + this.LanguageCount), + LanguageRecord.ReverseEndianness); + + public PointerSpan Storage => this.Memory[this.StorageOffset..]; + + public string this[in NameRecord record] => + record.PlatformAndEncoding.Decode(this.Storage.Span.Slice(record.StringOffset, record.Length)); + + public string this[in LanguageRecord record] => + Encoding.ASCII.GetString(this.Storage.Span.Slice(record.LanguageTagOffset, record.Length)); + + public struct NameRecord + { + public PlatformAndEncoding PlatformAndEncoding; + public ushort LanguageId; + public NameId NameId; + public ushort Length; + public ushort StringOffset; + + public NameRecord(PointerSpan span) + { + this.PlatformAndEncoding = new(span); + var offset = Unsafe.SizeOf(); + span.ReadBig(ref offset, out this.LanguageId); + span.ReadBig(ref offset, out this.NameId); + span.ReadBig(ref offset, out this.Length); + span.ReadBig(ref offset, out this.StringOffset); + } + + public static NameRecord ReverseEndianness(NameRecord value) => new() + { + PlatformAndEncoding = PlatformAndEncoding.ReverseEndianness(value.PlatformAndEncoding), + LanguageId = BinaryPrimitives.ReverseEndianness(value.LanguageId), + NameId = (NameId)BinaryPrimitives.ReverseEndianness((ushort)value.NameId), + Length = BinaryPrimitives.ReverseEndianness(value.Length), + StringOffset = BinaryPrimitives.ReverseEndianness(value.StringOffset), + }; + } + + public struct LanguageRecord + { + public ushort Length; + public ushort LanguageTagOffset; + + public LanguageRecord(PointerSpan span) + { + var offset = 0; + span.ReadBig(ref offset, out this.Length); + span.ReadBig(ref offset, out this.LanguageTagOffset); + } + + public static LanguageRecord ReverseEndianness(LanguageRecord value) => new() + { + Length = BinaryPrimitives.ReverseEndianness(value.Length), + LanguageTagOffset = BinaryPrimitives.ReverseEndianness(value.LanguageTagOffset), + }; + } + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.cs new file mode 100644 index 000000000..1d437d56d --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/TrueType.cs @@ -0,0 +1,135 @@ +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Interface.Utility; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas.Internals; + +/// +/// Deals with TrueType. +/// +internal static partial class TrueTypeUtils +{ + /// + /// Checks whether the given will fail in , + /// and throws an appropriate exception if it is the case. + /// + /// The font config. + public static unsafe void CheckImGuiCompatibleOrThrow(in ImFontConfig fontConfig) + { + var ranges = fontConfig.GlyphRanges; + var sfnt = AsSfntFile(fontConfig); + var cmap = new Cmap(sfnt); + if (cmap.UnicodeTable is not { } unicodeTable) + throw new NotSupportedException("The font does not have a compatible Unicode character mapping table."); + if (unicodeTable.All(x => !ImGuiHelpers.IsCodepointInSuppliedGlyphRangesUnsafe(x.Key, ranges))) + throw new NotSupportedException("The font does not have any glyph that falls under the requested range."); + } + + /// + /// Enumerates through horizontal pair adjustments of a kern and gpos tables. + /// + /// The font config. + /// The enumerable of pair adjustments. Distance values need to be multiplied by font size in pixels. + public static IEnumerable<(char Left, char Right, float Distance)> ExtractHorizontalPairAdjustments( + ImFontConfig fontConfig) + { + float multiplier; + Dictionary glyphToCodepoints; + Gpos gpos = default; + Kern kern = default; + + try + { + var sfnt = AsSfntFile(fontConfig); + var head = new Head(sfnt); + multiplier = 3f / 4 / head.UnitsPerEm; + + if (new Cmap(sfnt).UnicodeTable is not { } table) + yield break; + + if (sfnt.ContainsKey(Kern.DirectoryTableTag)) + kern = new(sfnt); + else if (sfnt.ContainsKey(Gpos.DirectoryTableTag)) + gpos = new(sfnt); + else + yield break; + + glyphToCodepoints = table + .GroupBy(x => x.Value, x => x.Key) + .OrderBy(x => x.Key) + .ToDictionary( + x => x.Key, + x => x.Where(y => y <= ushort.MaxValue) + .Select(y => (char)y) + .ToArray()); + } + catch + { + // don't care; give up + yield break; + } + + if (kern.Memory.Count != 0) + { + foreach (var pair in kern.EnumerateHorizontalPairs()) + { + if (!glyphToCodepoints.TryGetValue(pair.Left, out var leftChars)) + continue; + if (!glyphToCodepoints.TryGetValue(pair.Right, out var rightChars)) + continue; + + foreach (var l in leftChars) + { + foreach (var r in rightChars) + yield return (l, r, pair.Value * multiplier); + } + } + } + else if (gpos.Memory.Count != 0) + { + foreach (var pair in gpos.ExtractAdvanceX()) + { + if (!glyphToCodepoints.TryGetValue(pair.Left, out var leftChars)) + continue; + if (!glyphToCodepoints.TryGetValue(pair.Right, out var rightChars)) + continue; + + foreach (var l in leftChars) + { + foreach (var r in rightChars) + yield return (l, r, pair.Value * multiplier); + } + } + } + } + + private static unsafe SfntFile AsSfntFile(in ImFontConfig fontConfig) + { + var memory = new PointerSpan((byte*)fontConfig.FontData, fontConfig.FontDataSize); + if (memory.Length < 4) + throw new NotSupportedException("File is too short to even have a magic."); + + var magic = memory.ReadU32Big(0); + if (BitConverter.IsLittleEndian) + magic = BinaryPrimitives.ReverseEndianness(magic); + + if (magic == SfntFile.FileTagTrueType1.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagType1.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagOpenTypeWithCff.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagOpenType1_0.NativeValue) + return new(memory); + if (magic == SfntFile.FileTagTrueTypeApple.NativeValue) + return new(memory); + if (magic == TtcFile.FileTag.NativeValue) + return new TtcFile(memory)[fontConfig.FontNo]; + + throw new NotSupportedException($"The given file with the magic 0x{magic:X08} is not supported."); + } +} diff --git a/Dalamud/Interface/ManagedFontAtlas/SafeFontConfig.cs b/Dalamud/Interface/ManagedFontAtlas/SafeFontConfig.cs new file mode 100644 index 000000000..cb7f7c65a --- /dev/null +++ b/Dalamud/Interface/ManagedFontAtlas/SafeFontConfig.cs @@ -0,0 +1,306 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Text; + +using ImGuiNET; + +namespace Dalamud.Interface.ManagedFontAtlas; + +/// +/// Managed version of , to avoid unnecessary heap allocation and use of unsafe blocks. +/// +public struct SafeFontConfig +{ + /// + /// The raw config. + /// + public ImFontConfig Raw; + + /// + /// Initializes a new instance of the struct. + /// + public SafeFontConfig() + { + this.OversampleH = 1; + this.OversampleV = 1; + this.PixelSnapH = true; + this.GlyphMaxAdvanceX = float.MaxValue; + this.RasterizerMultiply = 1f; + this.RasterizerGamma = 1.4f; + this.EllipsisChar = unchecked((char)-1); + this.Raw.FontDataOwnedByAtlas = 1; + } + + /// + /// Initializes a new instance of the struct, + /// copying applicable values from an existing instance of . + /// + /// Config to copy from. + public unsafe SafeFontConfig(ImFontConfigPtr config) + : this() + { + if (config.NativePtr is not null) + { + this.Raw = *config.NativePtr; + this.Raw.GlyphRanges = null; + } + } + + /// + /// Gets or sets the index of font within a TTF/OTF file. + /// + public int FontNo + { + get => this.Raw.FontNo; + set => this.Raw.FontNo = EnsureRange(value, 0, int.MaxValue); + } + + /// + /// Gets or sets the desired size of the new font, in pixels.
+ /// Effectively, this is the line height.
+ /// Value is tied with . + ///
+ public float SizePx + { + get => this.Raw.SizePixels; + set => this.Raw.SizePixels = EnsureRange(value, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets the desired size of the new font, in points.
+ /// Effectively, this is the line height.
+ /// Value is tied with . + ///
+ public float SizePt + { + get => (this.Raw.SizePixels * 3) / 4; + set => this.Raw.SizePixels = EnsureRange((value * 4) / 3, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets the horizontal oversampling pixel count.
+ /// Rasterize at higher quality for sub-pixel positioning.
+ /// Note the difference between 2 and 3 is minimal so you can reduce this to 2 to save memory.
+ /// Read https://github.com/nothings/stb/blob/master/tests/oversample/README.md for details. + ///
+ public int OversampleH + { + get => this.Raw.OversampleH; + set => this.Raw.OversampleH = EnsureRange(value, 1, int.MaxValue); + } + + /// + /// Gets or sets the vertical oversampling pixel count.
+ /// Rasterize at higher quality for sub-pixel positioning.
+ /// This is not really useful as we don't use sub-pixel positions on the Y axis. + ///
+ public int OversampleV + { + get => this.Raw.OversampleV; + set => this.Raw.OversampleV = EnsureRange(value, 1, int.MaxValue); + } + + /// + /// Gets or sets a value indicating whether to align every glyph to pixel boundary.
+ /// Useful e.g. if you are merging a non-pixel aligned font with the default font.
+ /// If enabled, you can set and to 1. + ///
+ public bool PixelSnapH + { + get => this.Raw.PixelSnapH != 0; + set => this.Raw.PixelSnapH = value ? (byte)1 : (byte)0; + } + + /// + /// Gets or sets the extra spacing (in pixels) between glyphs.
+ /// Only X axis is supported for now.
+ /// Effectively, it is the letter spacing. + ///
+ public Vector2 GlyphExtraSpacing + { + get => this.Raw.GlyphExtraSpacing; + set => this.Raw.GlyphExtraSpacing = new( + EnsureRange(value.X, float.MinValue, float.MaxValue), + EnsureRange(value.Y, float.MinValue, float.MaxValue)); + } + + /// + /// Gets or sets the offset all glyphs from this font input.
+ /// Use this to offset fonts vertically when merging multiple fonts. + ///
+ public Vector2 GlyphOffset + { + get => this.Raw.GlyphOffset; + set => this.Raw.GlyphOffset = new( + EnsureRange(value.X, float.MinValue, float.MaxValue), + EnsureRange(value.Y, float.MinValue, float.MaxValue)); + } + + /// + /// Gets or sets the glyph ranges, which is a user-provided list of Unicode range. + /// Each range has 2 values, and values are inclusive.
+ /// The list must be zero-terminated.
+ /// If empty or null, then all the glyphs from the font that is in the range of UCS-2 will be added. + ///
+ public ushort[]? GlyphRanges { get; set; } + + /// + /// Gets or sets the minimum AdvanceX for glyphs.
+ /// Set only to align font icons.
+ /// Set both / to enforce mono-space font. + ///
+ public float GlyphMinAdvanceX + { + get => this.Raw.GlyphMinAdvanceX; + set => this.Raw.GlyphMinAdvanceX = + float.IsFinite(value) + ? value + : throw new ArgumentOutOfRangeException( + nameof(value), + value, + $"{nameof(this.GlyphMinAdvanceX)} must be a finite number."); + } + + /// + /// Gets or sets the maximum AdvanceX for glyphs. + /// + public float GlyphMaxAdvanceX + { + get => this.Raw.GlyphMaxAdvanceX; + set => this.Raw.GlyphMaxAdvanceX = + float.IsFinite(value) + ? value + : throw new ArgumentOutOfRangeException( + nameof(value), + value, + $"{nameof(this.GlyphMaxAdvanceX)} must be a finite number."); + } + + /// + /// Gets or sets a value that either brightens (>1.0f) or darkens (<1.0f) the font output.
+ /// Brightening small fonts may be a good workaround to make them more readable. + ///
+ public float RasterizerMultiply + { + get => this.Raw.RasterizerMultiply; + set => this.Raw.RasterizerMultiply = EnsureRange(value, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets the gamma value for fonts. + /// + public float RasterizerGamma + { + get => this.Raw.RasterizerGamma; + set => this.Raw.RasterizerGamma = EnsureRange(value, float.Epsilon, float.MaxValue); + } + + /// + /// Gets or sets a value explicitly specifying unicode codepoint of the ellipsis character.
+ /// When fonts are being merged first specified ellipsis will be used. + ///
+ public char EllipsisChar + { + get => (char)this.Raw.EllipsisChar; + set => this.Raw.EllipsisChar = value; + } + + /// + /// Gets or sets the desired name of the new font. Names longer than 40 bytes will be partially lost. + /// + public unsafe string Name + { + get + { + fixed (void* pName = this.Raw.Name) + { + var span = new ReadOnlySpan(pName, 40); + var firstNull = span.IndexOf((byte)0); + if (firstNull != -1) + span = span[..firstNull]; + return Encoding.UTF8.GetString(span); + } + } + + set + { + fixed (void* pName = this.Raw.Name) + { + var span = new Span(pName, 40); + Encoding.UTF8.GetBytes(value, span); + } + } + } + + /// + /// Gets or sets the desired font to merge with, if set. + /// + public unsafe ImFontPtr MergeFont + { + get => this.Raw.DstFont is not null ? this.Raw.DstFont : default; + set + { + this.Raw.MergeMode = value.NativePtr is null ? (byte)0 : (byte)1; + this.Raw.DstFont = value.NativePtr is null ? default : value.NativePtr; + } + } + + /// + /// Throws with appropriate messages, + /// if this has invalid values. + /// + public readonly void ThrowOnInvalidValues() + { + if (!(this.Raw.FontNo >= 0)) + throw new ArgumentException($"{nameof(this.FontNo)} must not be a negative number."); + + if (!(this.Raw.SizePixels > 0)) + throw new ArgumentException($"{nameof(this.SizePx)} must be a positive number."); + + if (!(this.Raw.OversampleH >= 1)) + throw new ArgumentException($"{nameof(this.OversampleH)} must be a negative number."); + + if (!(this.Raw.OversampleV >= 1)) + throw new ArgumentException($"{nameof(this.OversampleV)} must be a negative number."); + + if (!float.IsFinite(this.Raw.GlyphMinAdvanceX)) + throw new ArgumentException($"{nameof(this.GlyphMinAdvanceX)} must be a finite number."); + + if (!float.IsFinite(this.Raw.GlyphMaxAdvanceX)) + throw new ArgumentException($"{nameof(this.GlyphMaxAdvanceX)} must be a finite number."); + + if (!(this.Raw.RasterizerMultiply > 0)) + throw new ArgumentException($"{nameof(this.RasterizerMultiply)} must be a positive number."); + + if (!(this.Raw.RasterizerGamma > 0)) + throw new ArgumentException($"{nameof(this.RasterizerGamma)} must be a positive number."); + + if (this.GlyphRanges is { Length: > 0 } ranges) + { + if (ranges[0] == 0) + { + throw new ArgumentException( + "Font ranges cannot start with 0.", + nameof(this.GlyphRanges)); + } + + if (ranges[(ranges.Length - 1) & ~1] != 0) + { + throw new ArgumentException( + "Font ranges must terminate with a zero at even indices.", + nameof(this.GlyphRanges)); + } + } + } + + private static T EnsureRange(T value, T min, T max, [CallerMemberName] string callerName = "") + where T : INumber + { + if (value < min) + throw new ArgumentOutOfRangeException(callerName, value, $"{callerName} cannot be less than {min}."); + if (value > max) + throw new ArgumentOutOfRangeException(callerName, value, $"{callerName} cannot be more than {max}."); + + return value; + } +} diff --git a/Dalamud/Interface/UiBuilder.cs b/Dalamud/Interface/UiBuilder.cs index dd2e5bad3..55e11dfac 100644 --- a/Dalamud/Interface/UiBuilder.cs +++ b/Dalamud/Interface/UiBuilder.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; @@ -12,6 +11,9 @@ using Dalamud.Interface.GameFonts; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.ManagedAsserts; using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; +using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using ImGuiNET; using ImGuiScene; @@ -30,14 +32,20 @@ public sealed class UiBuilder : IDisposable private readonly HitchDetector hitchDetector; private readonly string namespaceName; private readonly InterfaceManager interfaceManager = Service.Get(); - private readonly GameFontManager gameFontManager = Service.Get(); + private readonly Framework framework = Service.Get(); [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); + private readonly DisposeSafety.ScopedFinalizer scopedFinalizer = new(); + private bool hasErrorWindow = false; private bool lastFrameUiHideState = false; + private IFontHandle? defaultFontHandle; + private IFontHandle? iconFontHandle; + private IFontHandle? monoFontHandle; + /// /// Initializes a new instance of the class and registers it. /// You do not have to call this manually. @@ -45,14 +53,32 @@ public sealed class UiBuilder : IDisposable /// The plugin namespace. internal UiBuilder(string namespaceName) { - this.stopwatch = new Stopwatch(); - this.hitchDetector = new HitchDetector($"UiBuilder({namespaceName})", this.configuration.UiBuilderHitch); - this.namespaceName = namespaceName; + try + { + this.stopwatch = new Stopwatch(); + this.hitchDetector = new HitchDetector($"UiBuilder({namespaceName})", this.configuration.UiBuilderHitch); + this.namespaceName = namespaceName; - this.interfaceManager.Draw += this.OnDraw; - this.interfaceManager.BuildFonts += this.OnBuildFonts; - this.interfaceManager.AfterBuildFonts += this.OnAfterBuildFonts; - this.interfaceManager.ResizeBuffers += this.OnResizeBuffers; + this.interfaceManager.Draw += this.OnDraw; + this.scopedFinalizer.Add(() => this.interfaceManager.Draw -= this.OnDraw); + + this.interfaceManager.ResizeBuffers += this.OnResizeBuffers; + this.scopedFinalizer.Add(() => this.interfaceManager.ResizeBuffers -= this.OnResizeBuffers); + + this.FontAtlas = + this.scopedFinalizer + .Add( + Service + .Get() + .CreateFontAtlas(namespaceName, FontAtlasAutoRebuildMode.Disable)); + this.FontAtlas.BuildStepChange += this.PrivateAtlasOnBuildStepChange; + this.FontAtlas.RebuildRecommend += this.RebuildFonts; + } + catch + { + this.scopedFinalizer.Dispose(); + throw; + } } /// @@ -78,21 +104,59 @@ public sealed class UiBuilder : IDisposable /// /// Gets or sets an action that is called any time ImGui fonts need to be rebuilt.
- /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt + /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt /// (at any time), so you should both reload your custom fonts and restore those - /// pointers inside this handler.
- /// PLEASE remove this handler inside Dispose, or when you no longer need your fonts! + /// pointers inside this handler. ///
- public event Action BuildFonts; + /// + /// To add your custom font, use . or + /// .
+ /// To be notified on font changes after fonts are built, use + /// ..
+ /// For all other purposes, use ..
+ ///
+ /// Note that you will be calling above functions once, instead of every time inside a build step change callback. + /// For example, you can make all font handles from your plugin constructor, and then use the created handles during + /// event, by using in a scope.
+ /// You may dispose your font handle anytime, as long as it's not in use in . + /// Font handles may be constructed anytime, as long as the owner or + /// is not disposed.
+ ///
+ /// If you were storing , consider if the job can be achieved solely by using + /// without directly using an instance of .
+ /// If you do need it, evaluate if you need to access fonts outside the main thread.
+ /// If it is the case, use to obtain a safe-to-access instance of + /// , once resolves.
+ /// Otherwise, use , and obtain the instance of via + /// . Do not let the escape the using scope.
+ ///
+ /// If your plugin sets to a non-default value, then + /// should be accessed using + /// , as the font handle member variables are only available + /// once drawing facilities are available.
+ ///
+ /// Examples:
+ /// * .
+ /// * .
+ /// * ctor.
+ /// * : + /// note how the construction of a new instance of and + /// call of are done in different functions, + /// without having to manually initiate font rebuild process. + ///
+ [Obsolete("See remarks.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public event Action? BuildFonts; /// /// Gets or sets an action that is called any time right after ImGui fonts are rebuilt.
- /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt + /// Any ImFontPtr objects that you store can be invalidated when fonts are rebuilt /// (at any time), so you should both reload your custom fonts and restore those - /// pointers inside this handler.
- /// PLEASE remove this handler inside Dispose, or when you no longer need your fonts! + /// pointers inside this handler. ///
- public event Action AfterBuildFonts; + [Obsolete($"See remarks for {nameof(BuildFonts)}.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public event Action? AfterBuildFonts; /// /// Gets or sets an action that is called when plugin UI or interface modifications are supposed to be shown. @@ -107,20 +171,89 @@ public sealed class UiBuilder : IDisposable public event Action HideUi; /// - /// Gets the default Dalamud font based on Noto Sans CJK Medium in 17pt - supporting all game languages and icons. + /// Gets the default Dalamud font size in points. + /// + public static float DefaultFontSizePt => InterfaceManager.DefaultFontSizePt; + + /// + /// Gets the default Dalamud font size in pixels. + /// + public static float DefaultFontSizePx => InterfaceManager.DefaultFontSizePx; + + /// + /// Gets the default Dalamud font - supporting all game languages and icons.
+ /// Accessing this static property outside of is dangerous and not supported. ///
public static ImFontPtr DefaultFont => InterfaceManager.DefaultFont; /// - /// Gets the default Dalamud icon font based on FontAwesome 5 Free solid in 17pt. + /// Gets the default Dalamud icon font based on FontAwesome 5 Free solid.
+ /// Accessing this static property outside of is dangerous and not supported. ///
public static ImFontPtr IconFont => InterfaceManager.IconFont; /// - /// Gets the default Dalamud monospaced font based on Inconsolata Regular in 16pt. + /// Gets the default Dalamud monospaced font based on Inconsolata Regular.
+ /// Accessing this static property outside of is dangerous and not supported. ///
public static ImFontPtr MonoFont => InterfaceManager.MonoFont; + /// + /// Gets the handle to the default Dalamud font - supporting all game languages and icons. + /// + /// + /// A font handle corresponding to this font can be obtained with: + /// + /// fontAtlas.NewDelegateFontHandle( + /// e => e.OnPreBuild( + /// tk => tk.AddDalamudDefaultFont(UiBuilder.DefaultFontSizePt))); + /// + /// + public IFontHandle DefaultFontHandle => + this.defaultFontHandle ??= + this.scopedFinalizer.Add( + new FontHandleWrapper( + this.InterfaceManagerWithScene?.DefaultFontHandle + ?? throw new InvalidOperationException("Scene is not yet ready."))); + + /// + /// Gets the default Dalamud icon font based on FontAwesome 5 Free solid. + /// + /// + /// A font handle corresponding to this font can be obtained with: + /// + /// fontAtlas.NewDelegateFontHandle( + /// e => e.OnPreBuild( + /// tk => tk.AddFontAwesomeIconFont(new() { SizePt = UiBuilder.DefaultFontSizePt }))); + /// + /// + public IFontHandle IconFontHandle => + this.iconFontHandle ??= + this.scopedFinalizer.Add( + new FontHandleWrapper( + this.InterfaceManagerWithScene?.IconFontHandle + ?? throw new InvalidOperationException("Scene is not yet ready."))); + + /// + /// Gets the default Dalamud monospaced font based on Inconsolata Regular. + /// + /// + /// A font handle corresponding to this font can be obtained with: + /// + /// fontAtlas.NewDelegateFontHandle( + /// e => e.OnPreBuild( + /// tk => tk.AddDalamudAssetFont( + /// DalamudAsset.InconsolataRegular, + /// new() { SizePt = UiBuilder.DefaultFontSizePt }))); + /// + /// + public IFontHandle MonoFontHandle => + this.monoFontHandle ??= + this.scopedFinalizer.Add( + new FontHandleWrapper( + this.InterfaceManagerWithScene?.MonoFontHandle + ?? throw new InvalidOperationException("Scene is not yet ready."))); + /// /// Gets the game's active Direct3D device. /// @@ -190,6 +323,11 @@ public sealed class UiBuilder : IDisposable ///
public bool UiPrepared => Service.GetNullable() != null; + /// + /// Gets the plugin-private font atlas. + /// + public IFontAtlas FontAtlas { get; } + /// /// Gets or sets a value indicating whether statistics about UI draw time should be collected. /// @@ -319,7 +457,7 @@ public sealed class UiBuilder : IDisposable if (runInFrameworkThread) { return this.InterfaceManagerWithSceneAsync - .ContinueWith(_ => Service.Get().RunOnFrameworkThread(func)) + .ContinueWith(_ => this.framework.RunOnFrameworkThread(func)) .Unwrap(); } else @@ -341,7 +479,7 @@ public sealed class UiBuilder : IDisposable if (runInFrameworkThread) { return this.InterfaceManagerWithSceneAsync - .ContinueWith(_ => Service.Get().RunOnFrameworkThread(func)) + .ContinueWith(_ => this.framework.RunOnFrameworkThread(func)) .Unwrap(); } else @@ -357,19 +495,50 @@ public sealed class UiBuilder : IDisposable ///
/// Font to get. /// Handle to the game font which may or may not be available for use yet. - public GameFontHandle GetGameFontHandle(GameFontStyle style) => this.gameFontManager.NewFontRef(style); + [Obsolete($"Use {nameof(this.FontAtlas)}.{nameof(IFontAtlas.NewGameFontHandle)} instead.", false)] + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + public GameFontHandle GetGameFontHandle(GameFontStyle style) => new( + (GamePrebakedFontHandle)this.FontAtlas.NewGameFontHandle(style), + Service.Get()); /// /// Call this to queue a rebuild of the font atlas.
- /// This will invoke any handlers and ensure that any loaded fonts are - /// ready to be used on the next UI frame. + /// This will invoke any and handlers and ensure that any + /// loaded fonts are ready to be used on the next UI frame. ///
public void RebuildFonts() { Log.Verbose("[FONT] {0} plugin is initiating FONT REBUILD", this.namespaceName); - this.interfaceManager.RebuildFonts(); + if (this.AfterBuildFonts is null && this.BuildFonts is null) + this.FontAtlas.BuildFontsAsync(); + else + this.FontAtlas.BuildFontsOnNextFrame(); } + /// + /// Creates an isolated . + /// + /// Specify when and how to rebuild this atlas. + /// Whether the fonts in the atlas is global scaled. + /// Name for debugging purposes. + /// A new instance of . + /// + /// Use this to create extra font atlases, if you want to create and dispose fonts without having to rebuild all + /// other fonts together.
+ /// If is not , + /// the font rebuilding functions must be called manually. + ///
+ public IFontAtlas CreateFontAtlas( + FontAtlasAutoRebuildMode autoRebuildMode, + bool isGlobalScaled = true, + string? debugName = null) => + this.scopedFinalizer.Add(Service + .Get() + .CreateFontAtlas( + this.namespaceName + ":" + (debugName ?? "custom"), + autoRebuildMode, + isGlobalScaled)); + /// /// Add a notification to the notification queue. /// @@ -392,12 +561,7 @@ public sealed class UiBuilder : IDisposable /// /// Unregister the UiBuilder. Do not call this in plugin code. /// - void IDisposable.Dispose() - { - this.interfaceManager.Draw -= this.OnDraw; - this.interfaceManager.BuildFonts -= this.OnBuildFonts; - this.interfaceManager.ResizeBuffers -= this.OnResizeBuffers; - } + void IDisposable.Dispose() => this.scopedFinalizer.Dispose(); /// /// Open the registered configuration UI, if it exists. @@ -463,8 +627,12 @@ public sealed class UiBuilder : IDisposable this.ShowUi?.InvokeSafely(); } - if (!this.interfaceManager.FontsReady) + // just in case, if something goes wrong, prevent drawing; otherwise it probably will crash. + if (!this.FontAtlas.BuildTask.IsCompletedSuccessfully + && (this.BuildFonts is not null || this.AfterBuildFonts is not null)) + { return; + } ImGui.PushID(this.namespaceName); if (DoStats) @@ -526,18 +694,89 @@ public sealed class UiBuilder : IDisposable this.hitchDetector.Stop(); } - private void OnBuildFonts() + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] + private unsafe void PrivateAtlasOnBuildStepChange(IFontAtlasBuildToolkit e) { - this.BuildFonts?.InvokeSafely(); - } + if (e.IsAsyncBuildOperation) + return; - private void OnAfterBuildFonts() - { - this.AfterBuildFonts?.InvokeSafely(); + ThreadSafety.AssertMainThread(); + + if (this.BuildFonts is not null) + { + e.OnPreBuild( + _ => + { + var prev = ImGui.GetIO().NativePtr->Fonts; + ImGui.GetIO().NativePtr->Fonts = e.NewImAtlas.NativePtr; + ((IFontAtlasBuildToolkit.IApi9Compat)e) + .FromUiBuilderObsoleteEventHandlers(() => this.BuildFonts?.InvokeSafely()); + ImGui.GetIO().NativePtr->Fonts = prev; + }); + } + + if (this.AfterBuildFonts is not null) + { + e.OnPostBuild( + _ => + { + var prev = ImGui.GetIO().NativePtr->Fonts; + ImGui.GetIO().NativePtr->Fonts = e.NewImAtlas.NativePtr; + ((IFontAtlasBuildToolkit.IApi9Compat)e) + .FromUiBuilderObsoleteEventHandlers(() => this.AfterBuildFonts?.InvokeSafely()); + ImGui.GetIO().NativePtr->Fonts = prev; + }); + } } private void OnResizeBuffers() { this.ResizeBuffers?.InvokeSafely(); } + + private class FontHandleWrapper : IFontHandle + { + private IFontHandle? wrapped; + + public FontHandleWrapper(IFontHandle wrapped) + { + this.wrapped = wrapped; + this.wrapped.ImFontChanged += this.WrappedOnImFontChanged; + } + + public event IFontHandle.ImFontChangedDelegate? ImFontChanged; + + public Exception? LoadException => this.WrappedNotDisposed.LoadException; + + public bool Available => this.WrappedNotDisposed.Available; + + private IFontHandle WrappedNotDisposed => + this.wrapped ?? throw new ObjectDisposedException(nameof(FontHandleWrapper)); + + public void Dispose() + { + if (this.wrapped is not { } w) + return; + + this.wrapped = null; + w.ImFontChanged -= this.WrappedOnImFontChanged; + // Note: do not dispose w; we do not own it + } + + public ILockedImFont Lock() => + this.wrapped?.Lock() ?? throw new ObjectDisposedException(nameof(FontHandleWrapper)); + + public IDisposable Push() => this.WrappedNotDisposed.Push(); + + public void Pop() => this.WrappedNotDisposed.Pop(); + + public Task WaitAsync() => + this.WrappedNotDisposed.WaitAsync().ContinueWith(_ => (IFontHandle)this); + + public override string ToString() => + $"{nameof(FontHandleWrapper)}({this.wrapped?.ToString() ?? "disposed"})"; + + private void WrappedOnImFontChanged(IFontHandle obj, ILockedImFont lockedFont) => + this.ImFontChanged?.Invoke(obj, lockedFont); + } } diff --git a/Dalamud/Interface/UldWrapper.cs b/Dalamud/Interface/UldWrapper.cs index 127ea85ec..dd8986bed 100644 --- a/Dalamud/Interface/UldWrapper.cs +++ b/Dalamud/Interface/UldWrapper.cs @@ -107,7 +107,7 @@ public class UldWrapper : IDisposable private IDalamudTextureWrap? CopyRect(int width, int height, byte[] rgbaData, UldRoot.PartData part) { - if (part.V + part.W > width || part.U + part.H > height) + if (part.U + part.W > width || part.V + part.H > height) { return null; } diff --git a/Dalamud/Interface/Utility/ImGuiHelpers.cs b/Dalamud/Interface/Utility/ImGuiHelpers.cs index ad151ec4e..444463d41 100644 --- a/Dalamud/Interface/Utility/ImGuiHelpers.cs +++ b/Dalamud/Interface/Utility/ImGuiHelpers.cs @@ -1,10 +1,15 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Numerics; +using System.Reactive.Disposables; using System.Runtime.InteropServices; +using System.Text.Unicode; using Dalamud.Configuration.Internal; using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility.Raii; using ImGuiNET; using ImGuiScene; @@ -31,8 +36,7 @@ public static class ImGuiHelpers /// This does not necessarily mean you can call drawing functions. /// public static unsafe bool IsImGuiInitialized => - ImGui.GetCurrentContext() is not (nint)0 // KW: IDEs get mad without the cast, despite being unnecessary - && ImGui.GetIO().NativePtr is not null; + ImGui.GetCurrentContext() != nint.Zero && ImGui.GetIO().NativePtr is not null; /// /// Gets the global Dalamud scale; even available before drawing is ready.
@@ -198,7 +202,7 @@ public static class ImGuiHelpers /// If a positive number is given, numbers will be rounded to this. public static unsafe void AdjustGlyphMetrics(this ImFontPtr fontPtr, float scale, float round = 0f) { - Func rounder = round > 0 ? x => MathF.Round(x * round) / round : x => x; + Func rounder = round > 0 ? x => MathF.Round(x / round) * round : x => x; var font = fontPtr.NativePtr; font->FontSize = rounder(font->FontSize * scale); @@ -310,6 +314,7 @@ public static class ImGuiHelpers glyph->U1, glyph->V1, glyph->AdvanceX * scale); + target.Mark4KPageUsedAfterGlyphAdd((ushort)glyph->Codepoint); changed = true; } else if (!missingOnly) @@ -343,25 +348,18 @@ public static class ImGuiHelpers } if (changed && rebuildLookupTable) - target.BuildLookupTableNonstandard(); - } + { + // ImGui resolves ' ' with FindGlyph, which uses FallbackGlyph. + // FallbackGlyph is resolved after resolving ' '. + // On the first call of BuildLookupTable, called from BuildFonts, FallbackGlyph is set to null, + // making FindGlyph return nullptr. + // On our secondary calls of BuildLookupTable, FallbackGlyph is set to some value that is not null, + // making ImGui attempt to treat whatever was there as a ' '. + // This may cause random glyphs to be sized randomly, if not an access violation exception. + target.NativePtr->FallbackGlyph = null; - /// - /// Call ImFont::BuildLookupTable, after attempting to fulfill some preconditions. - /// - /// The font. - public static unsafe void BuildLookupTableNonstandard(this ImFontPtr font) - { - // ImGui resolves ' ' with FindGlyph, which uses FallbackGlyph. - // FallbackGlyph is resolved after resolving ' '. - // On the first call of BuildLookupTable, called from BuildFonts, FallbackGlyph is set to null, - // making FindGlyph return nullptr. - // On our secondary calls of BuildLookupTable, FallbackGlyph is set to some value that is not null, - // making ImGui attempt to treat whatever was there as a ' '. - // This may cause random glyphs to be sized randomly, if not an access violation exception. - font.NativePtr->FallbackGlyph = null; - - font.BuildLookupTable(); + target.BuildLookupTable(); + } } /// @@ -407,6 +405,103 @@ public static class ImGuiHelpers public static void CenterCursorFor(float itemWidth) => ImGui.SetCursorPosX((int)((ImGui.GetWindowWidth() - itemWidth) / 2)); + /// + /// Allocates memory on the heap using
+ /// Memory must be freed using . + ///
+ /// Note that null is a valid return value when is 0. + ///
+ /// The length of allocated memory. + /// The allocated memory. + /// If returns null. + public static unsafe void* AllocateMemory(int length) + { + // TODO: igMemAlloc takes size_t, which is nint; ImGui.NET apparently interpreted that as uint. + // fix that in ImGui.NET. + switch (length) + { + case 0: + return null; + case < 0: + throw new ArgumentOutOfRangeException( + nameof(length), + length, + $"{nameof(length)} cannot be a negative number."); + default: + var memory = ImGuiNative.igMemAlloc((uint)length); + if (memory is null) + { + throw new OutOfMemoryException( + $"Failed to allocate {length} bytes using {nameof(ImGuiNative.igMemAlloc)}"); + } + + return memory; + } + } + + /// + /// Creates a new instance of with a natively backed memory. + /// + /// The created instance. + /// Disposable you can call. + public static unsafe IDisposable NewFontGlyphRangeBuilderPtrScoped(out ImFontGlyphRangesBuilderPtr builder) + { + builder = new(ImGuiNative.ImFontGlyphRangesBuilder_ImFontGlyphRangesBuilder()); + var ptr = builder.NativePtr; + return Disposable.Create(() => + { + if (ptr != null) + ImGuiNative.ImFontGlyphRangesBuilder_destroy(ptr); + ptr = null; + }); + } + + /// + /// Builds ImGui Glyph Ranges for use with . + /// + /// The builder. + /// Add fallback codepoints to the range. + /// Add ellipsis codepoints to the range. + /// When disposed, the resource allocated for the range will be freed. + public static unsafe ushort[] BuildRangesToArray( + this ImFontGlyphRangesBuilderPtr builder, + bool addFallbackCodepoints = true, + bool addEllipsisCodepoints = true) + { + if (addFallbackCodepoints) + builder.AddText(FontAtlasFactory.FallbackCodepoints); + if (addEllipsisCodepoints) + { + builder.AddText(FontAtlasFactory.EllipsisCodepoints); + builder.AddChar('.'); + } + + builder.BuildRanges(out var vec); + return new ReadOnlySpan((void*)vec.Data, vec.Size).ToArray(); + } + + /// + public static ushort[] CreateImGuiRangesFrom(params UnicodeRange[] ranges) + => CreateImGuiRangesFrom((IEnumerable)ranges); + + /// + /// Creates glyph ranges from .
+ /// Use values from . + ///
+ /// The unicode ranges. + /// The range array that can be used for . + public static ushort[] CreateImGuiRangesFrom(IEnumerable ranges) => + ranges + .Where(x => x.FirstCodePoint <= ushort.MaxValue) + .SelectMany( + x => new[] + { + (ushort)Math.Min(x.FirstCodePoint, ushort.MaxValue), + (ushort)Math.Min(x.FirstCodePoint + x.Length, ushort.MaxValue), + }) + .Append((ushort)0) + .ToArray(); + /// /// Determines whether is empty. /// @@ -415,7 +510,7 @@ public static class ImGuiHelpers public static unsafe bool IsNull(this ImFontPtr ptr) => ptr.NativePtr == null; /// - /// Determines whether is not null and loaded. + /// Determines whether is empty. /// /// The pointer. /// Whether it is empty. @@ -427,6 +522,27 @@ public static class ImGuiHelpers /// The pointer. /// Whether it is empty. public static unsafe bool IsNull(this ImFontAtlasPtr ptr) => ptr.NativePtr == null; + + /// + /// If is default, then returns . + /// + /// The self. + /// The other. + /// if it is not default; otherwise, . + public static unsafe ImFontPtr OrElse(this ImFontPtr self, ImFontPtr other) => + self.NativePtr is null ? other : self; + + /// + /// Mark 4K page as used, after adding a codepoint to a font. + /// + /// The font. + /// The codepoint. + internal static unsafe void Mark4KPageUsedAfterGlyphAdd(this ImFontPtr font, ushort codepoint) + { + // Mark 4K page as used + var pageIndex = unchecked((ushort)(codepoint / 4096)); + font.NativePtr->Used4kPagesMap[pageIndex >> 3] |= unchecked((byte)(1 << (pageIndex & 7))); + } /// /// Finds the corresponding ImGui viewport ID for the given window handle. @@ -448,6 +564,89 @@ public static class ImGuiHelpers return -1; } + /// + /// Attempts to validate that is valid. + /// + /// The font pointer. + /// The exception, if any occurred during validation. + internal static unsafe Exception? ValidateUnsafe(this ImFontPtr fontPtr) + { + try + { + var font = fontPtr.NativePtr; + if (font is null) + throw new NullReferenceException("The font is null."); + + _ = Marshal.ReadIntPtr((nint)font); + if (font->IndexedHotData.Data != 0) + _ = Marshal.ReadIntPtr(font->IndexedHotData.Data); + if (font->FrequentKerningPairs.Data != 0) + _ = Marshal.ReadIntPtr(font->FrequentKerningPairs.Data); + if (font->IndexLookup.Data != 0) + _ = Marshal.ReadIntPtr(font->IndexLookup.Data); + if (font->Glyphs.Data != 0) + _ = Marshal.ReadIntPtr(font->Glyphs.Data); + if (font->KerningPairs.Data != 0) + _ = Marshal.ReadIntPtr(font->KerningPairs.Data); + if (font->ConfigDataCount == 0 && font->ConfigData is not null) + throw new InvalidOperationException("ConfigDataCount == 0 but ConfigData is not null?"); + if (font->ConfigDataCount != 0 && font->ConfigData is null) + throw new InvalidOperationException("ConfigDataCount != 0 but ConfigData is null?"); + if (font->ConfigData is not null) + _ = Marshal.ReadIntPtr((nint)font->ConfigData); + if (font->FallbackGlyph is not null + && ((nint)font->FallbackGlyph < font->Glyphs.Data || (nint)font->FallbackGlyph >= font->Glyphs.Data)) + throw new InvalidOperationException("FallbackGlyph is not in range of Glyphs.Data"); + if (font->FallbackHotData is not null + && ((nint)font->FallbackHotData < font->IndexedHotData.Data + || (nint)font->FallbackHotData >= font->IndexedHotData.Data)) + throw new InvalidOperationException("FallbackGlyph is not in range of Glyphs.Data"); + if (font->ContainerAtlas is not null) + _ = Marshal.ReadIntPtr((nint)font->ContainerAtlas); + } + catch (Exception e) + { + return e; + } + + return null; + } + + /// + /// Updates the fallback char of . + /// + /// The font. + /// The fallback character. + internal static unsafe void UpdateFallbackChar(this ImFontPtr font, char c) + { + font.FallbackChar = c; + font.NativePtr->FallbackHotData = + (ImFontGlyphHotData*)((ImFontGlyphHotDataReal*)font.IndexedHotData.Data + font.FallbackChar); + } + + /// + /// Determines if the supplied codepoint is inside the given range, + /// in format of . + /// + /// The codepoint. + /// The ranges. + /// Whether it is the case. + internal static unsafe bool IsCodepointInSuppliedGlyphRangesUnsafe(int codepoint, ushort* rangePtr) + { + if (codepoint is <= 0 or >= ushort.MaxValue) + return false; + + while (*rangePtr != 0) + { + var from = *rangePtr++; + var to = *rangePtr++; + if (from <= codepoint && codepoint <= to) + return true; + } + + return false; + } + /// /// Get data needed for each new frame. /// diff --git a/Dalamud/Logging/Internal/ModuleLog.cs b/Dalamud/Logging/Internal/ModuleLog.cs index 5712f419b..1fe955294 100644 --- a/Dalamud/Logging/Internal/ModuleLog.cs +++ b/Dalamud/Logging/Internal/ModuleLog.cs @@ -33,7 +33,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Verbose(string messageTemplate, params object[] values) + public void Verbose(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Verbose, messageTemplate, null, values); /// @@ -43,7 +43,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Verbose(Exception exception, string messageTemplate, params object[] values) + public void Verbose(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Verbose, messageTemplate, exception, values); /// @@ -52,7 +52,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Debug(string messageTemplate, params object[] values) + public void Debug(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Debug, messageTemplate, null, values); /// @@ -62,7 +62,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Debug(Exception exception, string messageTemplate, params object[] values) + public void Debug(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Debug, messageTemplate, exception, values); /// @@ -71,7 +71,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Information(string messageTemplate, params object[] values) + public void Information(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Information, messageTemplate, null, values); /// @@ -81,7 +81,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Information(Exception exception, string messageTemplate, params object[] values) + public void Information(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Information, messageTemplate, exception, values); /// @@ -90,7 +90,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Warning(string messageTemplate, params object[] values) + public void Warning(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Warning, messageTemplate, null, values); /// @@ -100,7 +100,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Warning(Exception exception, string messageTemplate, params object[] values) + public void Warning(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Warning, messageTemplate, exception, values); /// @@ -109,7 +109,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Error(string messageTemplate, params object[] values) + public void Error(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Error, messageTemplate, null, values); /// @@ -119,7 +119,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - 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); /// @@ -128,7 +128,7 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Fatal(string messageTemplate, params object[] values) + public void Fatal(string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Fatal, messageTemplate, null, values); /// @@ -138,12 +138,12 @@ public class ModuleLog /// The message template. /// Values to log. [MessageTemplateFormatMethod("messageTemplate")] - public void Fatal(Exception exception, string messageTemplate, params object[] values) + public void Fatal(Exception? exception, string messageTemplate, params object?[] values) => this.WriteLog(LogEventLevel.Fatal, messageTemplate, exception, values); [MessageTemplateFormatMethod("messageTemplate")] private void WriteLog( - LogEventLevel level, string messageTemplate, Exception? exception = null, params object[] values) + LogEventLevel level, string messageTemplate, Exception? exception = null, params object?[] values) { // FIXME: Eventually, the `pluginName` tag should be removed from here and moved over to the actual log // formatter. diff --git a/Dalamud/Logging/PluginLog.cs b/Dalamud/Logging/PluginLog.cs index decf10b4c..e3744c617 100644 --- a/Dalamud/Logging/PluginLog.cs +++ b/Dalamud/Logging/PluginLog.cs @@ -1,6 +1,8 @@ using System.Reflection; using Dalamud.Plugin.Services; +using Dalamud.Utility; + using Serilog; using Serilog.Events; @@ -14,6 +16,7 @@ namespace Dalamud.Logging; /// move over as soon as reasonably possible for performance reasons. /// [Obsolete("Static PluginLog will be removed in API 10. Developers should use IPluginLog.")] +[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] public static class PluginLog { #region "Log" prefixed Serilog style methods diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index a3a4fb7e4..fce39d83c 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -666,6 +666,15 @@ internal partial class PluginManager : IDisposable, IServiceType this.PluginsReady = true; this.NotifyinstalledPluginsListChanged(); sigScanner.Save(); + + try + { + this.ParanoiaValidatePluginsAndProfiles(); + } + catch (Exception ex) + { + Log.Error(ex, "Plugin and profile validation failed!"); + } }, tokenSource.Token); } @@ -1088,7 +1097,7 @@ internal partial class PluginManager : IDisposable, IServiceType { try { - this.PluginConfigs.Delete(plugin.Name); + this.PluginConfigs.Delete(plugin.Manifest.InternalName); break; } catch (IOException) @@ -1259,6 +1268,30 @@ internal partial class PluginManager : IDisposable, IServiceType } } + /// + /// Check if there are any inconsistencies with our plugins, their IDs, and our profiles. + /// + private void ParanoiaValidatePluginsAndProfiles() + { + var seenIds = new List(); + + foreach (var installedPlugin in this.InstalledPlugins) + { + if (installedPlugin.Manifest.WorkingPluginId == Guid.Empty) + throw new Exception($"{(installedPlugin is LocalDevPlugin ? "DevPlugin" : "Plugin")} '{installedPlugin.Manifest.InternalName}' has an empty WorkingPluginId."); + + if (seenIds.Contains(installedPlugin.Manifest.WorkingPluginId)) + { + throw new Exception( + $"{(installedPlugin is LocalDevPlugin ? "DevPlugin" : "Plugin")} '{installedPlugin.Manifest.InternalName}' has a duplicate WorkingPluginId '{installedPlugin.Manifest.WorkingPluginId}'"); + } + + seenIds.Add(installedPlugin.Manifest.WorkingPluginId); + } + + this.profileManager.ParanoiaValidateProfiles(); + } + private async Task DownloadPluginAsync(RemotePluginManifest repoManifest, bool useTesting) { var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; @@ -1300,7 +1333,7 @@ internal partial class PluginManager : IDisposable, IServiceType try { // We don't need to apply, it doesn't matter - await this.profileManager.DefaultProfile.RemoveAsync(repoManifest.InternalName, false); + await this.profileManager.DefaultProfile.RemoveByInternalNameAsync(repoManifest.InternalName, false); } catch (ProfileOperationException) { @@ -1448,73 +1481,98 @@ internal partial class PluginManager : IDisposable, IServiceType if (isDev) { Log.Information($"Loading dev plugin {name}"); - var devPlugin = new LocalDevPlugin(dllFile, manifest); - loadPlugin &= !isBoot; - - var probablyInternalNameForThisPurpose = manifest?.InternalName ?? dllFile.Name; - - var wantsInDefaultProfile = - this.profileManager.DefaultProfile.WantsPlugin(probablyInternalNameForThisPurpose); - if (wantsInDefaultProfile == null) - { - // We don't know about this plugin, so we don't want to do anything here. - // The code below will take care of it and add it with the default value. - } - else if (wantsInDefaultProfile == false && devPlugin.StartOnBoot) - { - // We didn't want this plugin, and StartOnBoot is on. That means we don't want it and it should stay off until manually enabled. - Log.Verbose("DevPlugin {Name} disabled and StartOnBoot => disable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, false, false); - loadPlugin = false; - } - else if (wantsInDefaultProfile == true && devPlugin.StartOnBoot) - { - // We wanted this plugin, and StartOnBoot is on. That means we actually do want it. - Log.Verbose("DevPlugin {Name} enabled and StartOnBoot => enable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, true, false); - loadPlugin = !doNotLoad; - } - else if (wantsInDefaultProfile == true && !devPlugin.StartOnBoot) - { - // We wanted this plugin, but StartOnBoot is off. This means we don't want it anymore. - Log.Verbose("DevPlugin {Name} enabled and !StartOnBoot => disable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, false, false); - loadPlugin = false; - } - else if (wantsInDefaultProfile == false && !devPlugin.StartOnBoot) - { - // We didn't want this plugin, and StartOnBoot is off. We don't want it. - Log.Verbose("DevPlugin {Name} disabled and !StartOnBoot => disable", probablyInternalNameForThisPurpose); - await this.profileManager.DefaultProfile.AddOrUpdateAsync(probablyInternalNameForThisPurpose, false, false); - loadPlugin = false; - } - - plugin = devPlugin; + plugin = new LocalDevPlugin(dllFile, manifest); } else { Log.Information($"Loading plugin {name}"); plugin = new LocalPlugin(dllFile, manifest); } + + // Perform a migration from InternalName to GUIDs. The plugin should definitely have a GUID here. + // This will also happen if you are installing a plugin with the installer, and that's intended! + // It means that, if you have a profile which has unsatisfied plugins, installing a matching plugin will + // enter it into the profiles it can match. + if (plugin.Manifest.WorkingPluginId == Guid.Empty) + throw new Exception("Plugin should have a WorkingPluginId at this point"); + this.profileManager.MigrateProfilesToGuidsForPlugin(plugin.Manifest.InternalName, plugin.Manifest.WorkingPluginId); + + var wantedByAnyProfile = false; + + // Now, if this is a devPlugin, figure out if we want to load it + if (isDev) + { + var devPlugin = (LocalDevPlugin)plugin; + loadPlugin &= !isBoot; + + var wantsInDefaultProfile = + this.profileManager.DefaultProfile.WantsPlugin(plugin.Manifest.WorkingPluginId); + if (wantsInDefaultProfile == null) + { + // We don't know about this plugin, so we don't want to do anything here. + // The code below will take care of it and add it with the default value. + Log.Verbose("DevPlugin {Name} not wanted in default plugin", plugin.Manifest.InternalName); + + // Check if any profile wants this plugin. We need to do this here, since we want to allow loading a dev plugin if a non-default profile wants it active. + // Note that this will not add the plugin to the default profile. That's done below in any other case. + wantedByAnyProfile = await this.profileManager.GetWantStateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + + // If it is wanted by any other profile, we do want to load it. + if (wantedByAnyProfile) + loadPlugin = true; + } + else if (wantsInDefaultProfile == false && devPlugin.StartOnBoot) + { + // We didn't want this plugin, and StartOnBoot is on. That means we don't want it and it should stay off until manually enabled. + Log.Verbose("DevPlugin {Name} disabled and StartOnBoot => disable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + loadPlugin = false; + } + else if (wantsInDefaultProfile == true && devPlugin.StartOnBoot) + { + // We wanted this plugin, and StartOnBoot is on. That means we actually do want it. + Log.Verbose("DevPlugin {Name} enabled and StartOnBoot => enable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); + loadPlugin = !doNotLoad; + } + else if (wantsInDefaultProfile == true && !devPlugin.StartOnBoot) + { + // We wanted this plugin, but StartOnBoot is off. This means we don't want it anymore. + Log.Verbose("DevPlugin {Name} enabled and !StartOnBoot => disable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + loadPlugin = false; + } + else if (wantsInDefaultProfile == false && !devPlugin.StartOnBoot) + { + // We didn't want this plugin, and StartOnBoot is off. We don't want it. + Log.Verbose("DevPlugin {Name} disabled and !StartOnBoot => disable", plugin.Manifest.InternalName); + await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); + loadPlugin = false; + } + + plugin = devPlugin; + } #pragma warning disable CS0618 var defaultState = manifest?.Disabled != true && loadPlugin; #pragma warning restore CS0618 - - // Need to do this here, so plugins that don't load are still added to the default profile - var wantToLoad = await this.profileManager.GetWantStateAsync(plugin.Manifest.InternalName, defaultState); - + + // Plugins that aren't in any profile will be added to the default profile with this call. + // We are skipping a double-lookup for dev plugins that are wanted by non-default profiles, as noted above. + wantedByAnyProfile = wantedByAnyProfile || await this.profileManager.GetWantStateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, defaultState); + Log.Information("{Name} defaultState: {State} wantedByAnyProfile: {WantedByAny} loadPlugin: {LoadPlugin}", plugin.Manifest.InternalName, defaultState, wantedByAnyProfile, loadPlugin); + if (loadPlugin) { try { - if (wantToLoad && !plugin.IsOrphaned) + if (wantedByAnyProfile && !plugin.IsOrphaned) { await plugin.LoadAsync(reason); } else { - Log.Verbose($"{name} not loaded, wantToLoad:{wantToLoad} orphaned:{plugin.IsOrphaned}"); + Log.Verbose($"{name} not loaded, wantToLoad:{wantedByAnyProfile} orphaned:{plugin.IsOrphaned}"); } } catch (InvalidPluginException) diff --git a/Dalamud/Plugin/Internal/Profiles/Profile.cs b/Dalamud/Plugin/Internal/Profiles/Profile.cs index 592720c14..df5b045e2 100644 --- a/Dalamud/Plugin/Internal/Profiles/Profile.cs +++ b/Dalamud/Plugin/Internal/Profiles/Profile.cs @@ -102,7 +102,7 @@ internal class Profile /// Gets all plugins declared in this profile. /// public IEnumerable Plugins => - this.modelV1.Plugins.Select(x => new ProfilePluginEntry(x.InternalName, x.IsEnabled)); + this.modelV1.Plugins.Select(x => new ProfilePluginEntry(x.InternalName, x.WorkingPluginId, x.IsEnabled)); /// /// Gets this profile's underlying model. @@ -142,13 +142,13 @@ internal class Profile /// /// Check if this profile contains a specific plugin, and if it is enabled. /// - /// The internal name of the plugin. + /// The ID of the plugin. /// Null if this profile does not declare the plugin, true if the profile declares the plugin and wants it enabled, false if the profile declares the plugin and does not want it enabled. - public bool? WantsPlugin(string internalName) + public bool? WantsPlugin(Guid workingPluginId) { lock (this) { - var entry = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName); + var entry = this.modelV1.Plugins.FirstOrDefault(x => x.WorkingPluginId == workingPluginId); return entry?.IsEnabled; } } @@ -157,17 +157,18 @@ internal class Profile /// Add a plugin to this profile with the desired state, or change the state of a plugin in this profile. /// This will block until all states have been applied. /// - /// The internal name of the plugin. + /// The ID of the plugin. + /// The internal name of the plugin, if available. /// Whether or not the plugin should be enabled. /// Whether or not the current state should immediately be applied. /// A representing the asynchronous operation. - public async Task AddOrUpdateAsync(string internalName, bool state, bool apply = true) + public async Task AddOrUpdateAsync(Guid workingPluginId, string? internalName, bool state, bool apply = true) { - Debug.Assert(!internalName.IsNullOrEmpty(), "!internalName.IsNullOrEmpty()"); - + Debug.Assert(workingPluginId != Guid.Empty, "Trying to add plugin with empty guid"); + lock (this) { - var existing = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName); + var existing = this.modelV1.Plugins.FirstOrDefault(x => x.WorkingPluginId == workingPluginId); if (existing != null) { existing.IsEnabled = state; @@ -177,15 +178,55 @@ internal class Profile this.modelV1.Plugins.Add(new ProfileModelV1.ProfileModelV1Plugin { InternalName = internalName, + WorkingPluginId = workingPluginId, IsEnabled = state, }); } } // We need to remove this plugin from the default profile, if it declares it. - if (!this.IsDefaultProfile && this.manager.DefaultProfile.WantsPlugin(internalName) != null) + if (!this.IsDefaultProfile && this.manager.DefaultProfile.WantsPlugin(workingPluginId) != null) { - await this.manager.DefaultProfile.RemoveAsync(internalName, false); + await this.manager.DefaultProfile.RemoveAsync(workingPluginId, false); + } + + Service.Get().QueueSave(); + + if (apply) + await this.manager.ApplyAllWantStatesAsync(); + } + + /// + /// Remove a plugin from this profile. + /// This will block until all states have been applied. + /// + /// The ID of the plugin. + /// Whether or not the current state should immediately be applied. + /// A representing the asynchronous operation. + public async Task RemoveAsync(Guid workingPluginId, bool apply = true) + { + ProfileModelV1.ProfileModelV1Plugin entry; + lock (this) + { + entry = this.modelV1.Plugins.FirstOrDefault(x => x.WorkingPluginId == workingPluginId); + if (entry == null) + throw new PluginNotFoundException(workingPluginId); + + if (!this.modelV1.Plugins.Remove(entry)) + throw new Exception("Couldn't remove plugin from model collection"); + } + + // We need to add this plugin back to the default profile, if we were the last profile to have it. + if (!this.manager.IsInAnyProfile(workingPluginId)) + { + if (!this.IsDefaultProfile) + { + await this.manager.DefaultProfile.AddOrUpdateAsync(workingPluginId, entry.InternalName, this.IsEnabled && entry.IsEnabled, false); + } + else + { + throw new PluginNotInDefaultProfileException(workingPluginId.ToString()); + } } Service.Get().QueueSave(); @@ -201,36 +242,50 @@ internal class Profile /// The internal name of the plugin. /// Whether or not the current state should immediately be applied. /// A representing the asynchronous operation. - public async Task RemoveAsync(string internalName, bool apply = true) + public async Task RemoveByInternalNameAsync(string internalName, bool apply = true) { - ProfileModelV1.ProfileModelV1Plugin entry; + Guid? pluginToRemove = null; lock (this) { - entry = this.modelV1.Plugins.FirstOrDefault(x => x.InternalName == internalName); - if (entry == null) - throw new PluginNotFoundException(internalName); - - if (!this.modelV1.Plugins.Remove(entry)) - throw new Exception("Couldn't remove plugin from model collection"); + foreach (var plugin in this.Plugins) + { + if (plugin.InternalName.Equals(internalName, StringComparison.Ordinal)) + { + pluginToRemove = plugin.WorkingPluginId; + break; + } + } } - // We need to add this plugin back to the default profile, if we were the last profile to have it. - if (!this.manager.IsInAnyProfile(internalName)) + await this.RemoveAsync(pluginToRemove ?? throw new PluginNotFoundException(internalName), apply); + } + + /// + /// This function tries to migrate all plugins with this internalName which do not have + /// a GUID to the specified GUID. + /// This is best-effort and will probably work well for anyone that only uses regular plugins. + /// + /// InternalName of the plugin to migrate. + /// Guid to use. + public void MigrateProfilesToGuidsForPlugin(string internalName, Guid newGuid) + { + lock (this) { - if (!this.IsDefaultProfile) + foreach (var plugin in this.modelV1.Plugins) { - await this.manager.DefaultProfile.AddOrUpdateAsync(internalName, this.IsEnabled && entry.IsEnabled, false); - } - else - { - throw new PluginNotInDefaultProfileException(internalName); + // TODO: What should happen if a profile has a GUID locked in, but the plugin + // is not installed anymore? That probably means that the user uninstalled the plugin + // and is now reinstalling it. We should still satisfy that and update the ID. + + if (plugin.InternalName == internalName && plugin.WorkingPluginId == Guid.Empty) + { + plugin.WorkingPluginId = newGuid; + Log.Information("Migrated profile {Profile} plugin {Name} to guid {Guid}", this, internalName, newGuid); + } } } - + Service.Get().QueueSave(); - - if (apply) - await this.manager.ApplyAllWantStatesAsync(); } /// @@ -280,4 +335,13 @@ internal sealed class PluginNotFoundException : ProfileOperationException : base($"The plugin '{internalName}' was not found in the profile") { } + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the plugin causing the error. + public PluginNotFoundException(Guid workingPluginId) + : base($"The plugin '{workingPluginId}' was not found in the profile") + { + } } diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs b/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs index 46b572c1a..10d94de73 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileManager.cs @@ -69,11 +69,12 @@ internal class ProfileManager : IServiceType /// /// Check if any enabled profile wants a specific plugin enabled. /// - /// The internal name of the plugin. + /// The ID of the plugin. + /// The internal name of the plugin, if available. /// The state the plugin shall be in, if it needs to be added. /// Whether or not the plugin should be added to the default preset, if it's not present in any preset. /// Whether or not the plugin shall be enabled. - public async Task GetWantStateAsync(string internalName, bool defaultState, bool addIfNotDeclared = true) + public async Task GetWantStateAsync(Guid workingPluginId, string? internalName, bool defaultState, bool addIfNotDeclared = true) { var want = false; var wasInAnyProfile = false; @@ -82,7 +83,7 @@ internal class ProfileManager : IServiceType { foreach (var profile in this.profiles) { - var state = profile.WantsPlugin(internalName); + var state = profile.WantsPlugin(workingPluginId); if (state.HasValue) { want = want || (profile.IsEnabled && state.Value); @@ -93,8 +94,8 @@ internal class ProfileManager : IServiceType if (!wasInAnyProfile && addIfNotDeclared) { - Log.Warning("{Name} was not in any profile, adding to default with {Default}", internalName, defaultState); - await this.DefaultProfile.AddOrUpdateAsync(internalName, defaultState, false); + Log.Warning("'{Guid}'('{InternalName}') was not in any profile, adding to default with {Default}", workingPluginId, internalName, defaultState); + await this.DefaultProfile.AddOrUpdateAsync(workingPluginId, internalName, defaultState, false); return defaultState; } @@ -105,22 +106,22 @@ internal class ProfileManager : IServiceType /// /// Check whether a plugin is declared in any profile. /// - /// The internal name of the plugin. + /// The ID of the plugin. /// Whether or not the plugin is in any profile. - public bool IsInAnyProfile(string internalName) + public bool IsInAnyProfile(Guid workingPluginId) { lock (this.profiles) - return this.profiles.Any(x => x.WantsPlugin(internalName) != null); + return this.profiles.Any(x => x.WantsPlugin(workingPluginId) != null); } /// /// Check whether a plugin is only in the default profile. /// A plugin can never be in the default profile if it is in any other profile. /// - /// The internal name of the plugin. + /// The ID of the plugin. /// Whether or not the plugin is in the default profile. - public bool IsInDefaultProfile(string internalName) - => this.DefaultProfile.WantsPlugin(internalName) != null; + public bool IsInDefaultProfile(Guid workingPluginId) + => this.DefaultProfile.WantsPlugin(workingPluginId) != null; /// /// Add a new profile. @@ -151,7 +152,7 @@ internal class ProfileManager : IServiceType /// The newly cloned profile. public Profile CloneProfile(Profile toClone) { - var newProfile = this.ImportProfile(toClone.Model.Serialize()); + var newProfile = this.ImportProfile(toClone.Model.SerializeForShare()); if (newProfile == null) throw new Exception("New profile was null while cloning"); @@ -172,7 +173,27 @@ internal class ProfileManager : IServiceType newModel.Guid = Guid.NewGuid(); newModel.Name = this.GenerateUniqueProfileName(newModel.Name.IsNullOrEmpty() ? "Unknown Collection" : newModel.Name); if (newModel is ProfileModelV1 modelV1) + { + // Disable it modelV1.IsEnabled = false; + + // Try to find matching plugins for all plugins in the profile + var pm = Service.Get(); + foreach (var plugin in modelV1.Plugins) + { + var installedPlugin = pm.InstalledPlugins.FirstOrDefault(x => x.Manifest.InternalName == plugin.InternalName); + if (installedPlugin != null) + { + Log.Information("Satisfying plugin {InternalName} for profile {Name} with {Guid}", plugin.InternalName, newModel.Name, installedPlugin.Manifest.WorkingPluginId); + plugin.WorkingPluginId = installedPlugin.Manifest.WorkingPluginId; + } + else + { + Log.Warning("Couldn't find plugin {InternalName} for profile {Name}", plugin.InternalName, newModel.Name); + plugin.WorkingPluginId = Guid.Empty; + } + } + } this.config.SavedProfiles!.Add(newModel); this.config.QueueSave(); @@ -196,19 +217,18 @@ internal class ProfileManager : IServiceType this.isBusy = true; Log.Information("Getting want states..."); - List wantActive; + List wantActive; lock (this.profiles) { wantActive = this.profiles .Where(x => x.IsEnabled) - .SelectMany(profile => profile.Plugins.Where(plugin => plugin.IsEnabled) - .Select(plugin => plugin.InternalName)) + .SelectMany(profile => profile.Plugins.Where(plugin => plugin.IsEnabled)) .Distinct().ToList(); } - foreach (var internalName in wantActive) + foreach (var profilePluginEntry in wantActive) { - Log.Information("\t=> Want {Name}", internalName); + Log.Information("\t=> Want {Name}({WorkingPluginId})", profilePluginEntry.InternalName, profilePluginEntry.WorkingPluginId); } Log.Information("Applying want states..."); @@ -218,7 +238,7 @@ internal class ProfileManager : IServiceType var pm = Service.Get(); foreach (var installedPlugin in pm.InstalledPlugins) { - var wantThis = wantActive.Contains(installedPlugin.Manifest.InternalName); + var wantThis = wantActive.Any(x => x.WorkingPluginId == installedPlugin.Manifest.WorkingPluginId); switch (wantThis) { case true when !installedPlugin.IsLoaded: @@ -267,7 +287,7 @@ internal class ProfileManager : IServiceType // We need to remove all plugins from the profile first, so that they are re-added to the default profile if needed foreach (var plugin in profile.Plugins.ToArray()) { - await profile.RemoveAsync(plugin.InternalName, false); + await profile.RemoveAsync(plugin.WorkingPluginId, false); } if (!this.config.SavedProfiles!.Remove(profile.Model)) @@ -279,6 +299,42 @@ internal class ProfileManager : IServiceType this.config.QueueSave(); } + /// + /// This function tries to migrate all plugins with this internalName which do not have + /// a GUID to the specified GUID. + /// This is best-effort and will probably work well for anyone that only uses regular plugins. + /// + /// InternalName of the plugin to migrate. + /// Guid to use. + public void MigrateProfilesToGuidsForPlugin(string internalName, Guid newGuid) + { + lock (this.profiles) + { + foreach (var profile in this.profiles) + profile.MigrateProfilesToGuidsForPlugin(internalName, newGuid); + } + } + + /// + /// Validate profiles for errors. + /// + /// Thrown when a profile is not sane. + public void ParanoiaValidateProfiles() + { + foreach (var profile in this.profiles) + { + var seenIds = new List(); + + foreach (var pluginEntry in profile.Plugins) + { + if (seenIds.Contains(pluginEntry.WorkingPluginId)) + throw new Exception($"Plugin '{pluginEntry.WorkingPluginId}'('{pluginEntry.InternalName}') is twice in profile '{profile.Guid}'('{profile.Name}')"); + + seenIds.Add(pluginEntry.WorkingPluginId); + } + } + } + private string GenerateUniqueProfileName(string startingWith) { if (this.profiles.All(x => x.Name != startingWith)) diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs b/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs index bf2a9c2c9..e3d9e2955 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileModel.cs @@ -1,7 +1,9 @@ -using System; +using System.Collections.Generic; +using System.Reflection; using Dalamud.Utility; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; namespace Dalamud.Plugin.Internal.Profiles; @@ -39,11 +41,11 @@ public abstract class ProfileModel } /// - /// Serialize this model into a string usable for sharing. + /// Serialize this model into a string usable for sharing, without including GUIDs. /// /// The serialized representation of the model. /// Thrown when an unsupported model is serialized. - public string Serialize() + public string SerializeForShare() { string prefix; switch (this) @@ -55,6 +57,32 @@ public abstract class ProfileModel throw new ArgumentOutOfRangeException(); } - return prefix + Convert.ToBase64String(Util.CompressString(JsonConvert.SerializeObject(this))); + // HACK: Just filter the ID for now, we should split the sharing + saving model + var serialized = JsonConvert.SerializeObject(this, new JsonSerializerSettings() + { ContractResolver = new IgnorePropertiesResolver(new[] { "WorkingPluginId" }) }); + + return prefix + Convert.ToBase64String(Util.CompressString(serialized)); + } + + // Short helper class to ignore some properties from serialization + private class IgnorePropertiesResolver : DefaultContractResolver + { + private readonly HashSet ignoreProps; + + public IgnorePropertiesResolver(IEnumerable propNamesToIgnore) + { + this.ignoreProps = new HashSet(propNamesToIgnore); + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + if (this.ignoreProps.Contains(property.PropertyName)) + { + property.ShouldSerialize = _ => false; + } + + return property; + } } } diff --git a/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs b/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs index 2a851d234..99da4263b 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfileModelV1.cs @@ -46,6 +46,11 @@ public class ProfileModelV1 : ProfileModel /// Gets or sets the internal name of the plugin. /// public string? InternalName { get; set; } + + /// + /// Gets or sets an ID uniquely identifying this specific instance of a plugin. + /// + public Guid WorkingPluginId { get; set; } /// /// Gets or sets a value indicating whether or not this entry is enabled. diff --git a/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs b/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs index 0a6f5140b..7909981bc 100644 --- a/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs +++ b/Dalamud/Plugin/Internal/Profiles/ProfilePluginEntry.cs @@ -9,10 +9,12 @@ internal class ProfilePluginEntry /// Initializes a new instance of the class. /// /// The internal name of the plugin. + /// The ID of the plugin. /// A value indicating whether or not this entry is enabled. - public ProfilePluginEntry(string internalName, bool state) + public ProfilePluginEntry(string internalName, Guid workingPluginId, bool state) { this.InternalName = internalName; + this.WorkingPluginId = workingPluginId; this.IsEnabled = state; } @@ -20,6 +22,11 @@ internal class ProfilePluginEntry /// Gets the internal name of the plugin. /// public string InternalName { get; } + + /// + /// Gets or sets an ID uniquely identifying this specific instance of a plugin. + /// + public Guid WorkingPluginId { get; set; } /// /// Gets a value indicating whether or not this entry is enabled. diff --git a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs index 20d108a38..e438c6f92 100644 --- a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs +++ b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs @@ -164,7 +164,7 @@ internal class LocalPlugin : IDisposable /// INCLUDES the default profile. /// public bool IsWantedByAnyProfile => - Service.Get().GetWantStateAsync(this.manifest.InternalName, false, false).GetAwaiter().GetResult(); + Service.Get().GetWantStateAsync(this.manifest.WorkingPluginId, this.Manifest.InternalName, false, false).GetAwaiter().GetResult(); /// /// Gets a value indicating whether this plugin's API level is out of date. diff --git a/Dalamud/Storage/Assets/DalamudAssetManager.cs b/Dalamud/Storage/Assets/DalamudAssetManager.cs index 70a91c4bf..7edb1c61d 100644 --- a/Dalamud/Storage/Assets/DalamudAssetManager.cs +++ b/Dalamud/Storage/Assets/DalamudAssetManager.cs @@ -69,6 +69,14 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA .Select(x => x.ToContentDisposedTask())) .ContinueWith(_ => loadTimings.Dispose()), "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.GetAttribute()?.Required is false) + .Select(this.CreateStreamAsync) + .Select(x => x.ToContentDisposedTask())) + .ContinueWith(r => Log.Verbose($"Optional assets load state: {r}")); } /// diff --git a/Dalamud/Storage/Assets/IDalamudAssetManager.cs b/Dalamud/Storage/Assets/IDalamudAssetManager.cs index 4fb83df80..1202891b8 100644 --- a/Dalamud/Storage/Assets/IDalamudAssetManager.cs +++ b/Dalamud/Storage/Assets/IDalamudAssetManager.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.Contracts; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; using System.IO; using System.Threading.Tasks; @@ -64,8 +65,9 @@ internal interface IDalamudAssetManager /// /// The texture asset. /// The default return value, if the asset is not ready for whatever reason. - /// The texture wrap. + /// The texture wrap. Can be null only if is null. [Pure] + [return: NotNullIfNotNull(nameof(defaultWrap))] IDalamudTextureWrap? GetDalamudTextureWrap(DalamudAsset asset, IDalamudTextureWrap? defaultWrap); /// diff --git a/Dalamud/Utility/Api10ToDoAttribute.cs b/Dalamud/Utility/Api10ToDoAttribute.cs new file mode 100644 index 000000000..f397f8f0c --- /dev/null +++ b/Dalamud/Utility/Api10ToDoAttribute.cs @@ -0,0 +1,19 @@ +namespace Dalamud.Utility; + +/// +/// Utility class for marking something to be changed for API 10, for ease of lookup. +/// +[AttributeUsage(AttributeTargets.All, Inherited = false)] +internal sealed class Api10ToDoAttribute : Attribute +{ + /// + /// Marks that this exists purely for making API 9 plugins work. + /// + public const string DeleteCompatBehavior = "Delete. This is for making API 9 plugins work."; + + /// + /// Initializes a new instance of the class. + /// + /// The explanation. + public Api10ToDoAttribute(string what) => _ = what; +} diff --git a/Dalamud/Utility/IRefCountable.cs b/Dalamud/Utility/IRefCountable.cs new file mode 100644 index 000000000..76d1059d1 --- /dev/null +++ b/Dalamud/Utility/IRefCountable.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.Threading; + +namespace Dalamud.Utility; + +/// +/// Interface for reference counting. +/// +internal interface IRefCountable : IDisposable +{ + /// + /// Result for . + /// + public enum RefCountResult + { + /// + /// The object still has remaining references. No futher action should be done. + /// + StillAlive = 1, + + /// + /// The last reference to the object has been released. The object should be fully released. + /// + FinalRelease = 2, + + /// + /// The object already has been disposed. may be thrown. + /// + AlreadyDisposed = 3, + } + + /// + /// Adds a reference to this reference counted object. + /// + /// The new number of references. + int AddRef(); + + /// + /// Releases a reference from this reference counted object.
+ /// When all references are released, the object will be fully disposed. + ///
+ /// The new number of references. + int Release(); + + /// + /// Alias for . + /// + void IDisposable.Dispose() => this.Release(); + + /// + /// Alters by . + /// + /// The delta to the reference count. + /// The reference to the reference count. + /// The new reference count. + /// The followup action that should be done. + public static RefCountResult AlterRefCount(int delta, ref int refCount, out int newRefCount) + { + Debug.Assert(delta is 1 or -1, "delta must be 1 or -1"); + + while (true) + { + var refCountCopy = refCount; + if (refCountCopy <= 0) + { + newRefCount = refCountCopy; + return RefCountResult.AlreadyDisposed; + } + + newRefCount = refCountCopy + delta; + if (refCountCopy != Interlocked.CompareExchange(ref refCount, newRefCount, refCountCopy)) + continue; + + return newRefCount == 0 ? RefCountResult.FinalRelease : RefCountResult.StillAlive; + } + } +} diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index a4c346c80..5aa50b1c1 --- a/build.sh +++ b/build.sh @@ -59,4 +59,4 @@ fi echo "Microsoft (R) .NET Core SDK version $("$DOTNET_EXE" --version)" "$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false /p:EnableWindowsTargeting=true -nologo -clp:NoSummary --verbosity quiet -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- /p:EnableWindowsTargeting=true "$@" +"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index bbc4b9942..e3bd59106 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit bbc4b994254d6913f51da3a20fad9bf4b8c986e5 +Subproject commit e3bd5910678683a718e68f0f940c88b08c24eba5