diff --git a/Dalamud.sln.DotSettings b/Dalamud.sln.DotSettings index ba114a776..4653085d4 100644 --- a/Dalamud.sln.DotSettings +++ b/Dalamud.sln.DotSettings @@ -49,6 +49,7 @@ True True True + True True True True diff --git a/Dalamud/Game/Internal/Gui/FlyTextGui.cs b/Dalamud/Game/Internal/Gui/FlyTextGui.cs new file mode 100644 index 000000000..5d7e9eef9 --- /dev/null +++ b/Dalamud/Game/Internal/Gui/FlyTextGui.cs @@ -0,0 +1,321 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.UI; +using Serilog; + +namespace Dalamud.Game.Internal.Gui +{ + /// + /// This class facilitates interacting with and creating native in-game "fly text". + /// + public sealed class FlyTextGui : IDisposable + { + /// + /// The native function responsible for adding fly text to the UI. See . + /// + private readonly AddFlyTextDelegate addFlyTextNative; + + /// + /// The hook that fires when the game creates a fly text element. See . + /// + private readonly Hook createFlyTextHook; + + private readonly Stopwatch hookTimer; + + /// + /// Initializes a new instance of the class. + /// + /// The SigScanner instance. + /// The Dalamud instance. + internal FlyTextGui(SigScanner scanner, Dalamud dalamud) + { + this.Dalamud = dalamud; + + this.hookTimer = new Stopwatch(); + + this.Address = new FlyTextGuiAddressResolver(); + this.Address.Setup(scanner); + + this.addFlyTextNative = Marshal.GetDelegateForFunctionPointer(this.Address.AddFlyText); + this.createFlyTextHook = new Hook(this.Address.CreateFlyText, this.CreateFlyTextDetour); + } + + /// + /// The delegate defining the type for the FlyText event. + /// + /// The FlyTextKind. See . + /// Value1 passed to the native flytext function. + /// Value2 passed to the native flytext function. Seems unused. + /// Text1 passed to the native flytext function. + /// Text2 passed to the native flytext function. + /// Color passed to the native flytext function. Changes flytext color. + /// Icon ID passed to the native flytext function. Only displays with select FlyTextKind. + /// The vertical offset to place the flytext at. 0 is default. Negative values result + /// in text appearing higher on the screen. This does not change where the element begins to fade. + /// Whether this flytext has been handled. If a subscriber sets this to true, the FlyText will not appear. + public delegate void FlyTextDelegate( + ref FlyTextKind kind, + ref int val1, + ref int val2, + ref SeString text1, + ref SeString text2, + ref uint color, + ref uint icon, + ref float yOffset, + ref bool handled); + + /// + /// Private delegate for the native CreateFlyText function's hook. + /// + private delegate IntPtr CreateFlyTextDelegate( + IntPtr addonFlyText, + int kind, + int val1, + int val2, + IntPtr text2, + uint color, + uint icon, + IntPtr text1, + float unk3); + + /// + /// Private delegate for the native AddFlyText function pointer. + /// + private delegate void AddFlyTextDelegate( + IntPtr addonFlyText, + uint actorIndex, + uint messageMax, + IntPtr numbers, + uint offsetNum, + uint offsetNumMax, + IntPtr strings, + uint offsetStr, + uint offsetStrMax, + int unknown); + + /// + /// The FlyText event that can be subscribed to. + /// + public event FlyTextDelegate? OnFlyText; + + private Dalamud Dalamud { get; } + + private FlyTextGuiAddressResolver Address { get; } + + /// + /// Disposes of managed and unmanaged resources. + /// + public void Dispose() + { + this.createFlyTextHook.Disable(); + this.createFlyTextHook.Dispose(); + } + + /// + /// Displays a fly text in-game on the local player. + /// + /// The FlyTextKind. See . + /// The index of the actor to place flytext on. Indexing unknown. 1 places flytext on local player. + /// Value1 passed to the native flytext function. + /// Value2 passed to the native flytext function. Seems unused. + /// Text1 passed to the native flytext function. + /// Text2 passed to the native flytext function. + /// Color passed to the native flytext function. Changes flytext color. + /// Icon ID passed to the native flytext function. Only displays with select FlyTextKind. + public unsafe void AddFlyText(FlyTextKind kind, uint actorIndex, uint val1, uint val2, SeString text1, SeString text2, uint color, uint icon) + { + // Known valid flytext region within the atk arrays + int numIndex = 28; + int strIndex = 25; + uint numOffset = 147; + uint strOffset = 28; + + // Get the UI module and flytext addon pointers + var ui = (UIModule*)this.Dalamud.Framework.Gui.GetUIModule(); + var flytext = this.Dalamud.Framework.Gui.GetUiObjectByName("_FlyText", 1); + + if (ui == null || flytext == IntPtr.Zero) + return; + + // Get the number and string arrays we need + var atkArrayDataHolder = ui->RaptureAtkModule.AtkModule.AtkArrayDataHolder; + var numArray = atkArrayDataHolder._NumberArrays[numIndex]; + var strArray = atkArrayDataHolder._StringArrays[strIndex]; + + // Write the values to the arrays using a known valid flytext region + + // Whether or not to enable this set of values for displaying flytext + numArray->IntArray[numOffset + 0] = 1; // Some kind of "Enabled" flag for this section + numArray->IntArray[numOffset + 1] = (int)kind; + numArray->IntArray[numOffset + 2] = unchecked((int)val1); + numArray->IntArray[numOffset + 3] = unchecked((int)val2); + numArray->IntArray[numOffset + 4] = 5; // Unknown + numArray->IntArray[numOffset + 5] = unchecked((int)color); + numArray->IntArray[numOffset + 6] = unchecked((int)icon); + numArray->IntArray[numOffset + 7] = 0; // Unknown + numArray->IntArray[numOffset + 8] = 0; // Unknown, has something to do with yOffset + + fixed (byte* pText1 = text1.Encode()) + { + fixed (byte* pText2 = text2.Encode()) + { + strArray->StringArray[strOffset + 0] = pText1; + strArray->StringArray[strOffset + 1] = pText2; + + this.addFlyTextNative( + flytext, + actorIndex, + 1, + (IntPtr)numArray, + numOffset, + 9, + (IntPtr)strArray, + strOffset, + 2, + 0); + } + } + } + + /// + /// Enables this module. + /// + internal void Enable() + { + this.createFlyTextHook.Enable(); + } + + private IntPtr CreateFlyTextDetour( + IntPtr addonFlyText, + int kind, + int val1, + int val2, + IntPtr text2, + uint color, + uint icon, + IntPtr text1, + float yOffset) + { + var retVal = IntPtr.Zero; + try + { + this.hookTimer.Restart(); + Log.Verbose("[FlyText] Enter CreateFlyText detour!"); + + var handled = false; + + var tmpKind = (FlyTextKind)kind; + var tmpVal1 = val1; + var tmpVal2 = val2; + var tmpText1 = this.Dalamud.SeStringManager.Parse(text1); + var tmpText2 = this.Dalamud.SeStringManager.Parse(text2); + var tmpColor = color; + var tmpIcon = icon; + var tmpYOffset = yOffset; + + var cmpText1 = tmpText1.ToString(); + var cmpText2 = tmpText2.ToString(); + + Log.Verbose($"[FlyText] Called with addonFlyText({addonFlyText.ToInt64():X}) " + + $"kind({((FlyTextKind)kind).ToString()}) val1({val1}) val2({val2}) " + + $"text1({text1.ToInt64():X}, \"{tmpText1}\") text2({text2.ToInt64():X}, \"{tmpText2}\") " + + $"color({color:X}) icon({icon}) yOffset({yOffset})"); + Log.Verbose("[FlyText] Calling flytext events!"); + this.OnFlyText?.Invoke( + ref tmpKind, + ref tmpVal1, + ref tmpVal2, + ref tmpText1, + ref tmpText2, + ref tmpColor, + ref tmpIcon, + ref tmpYOffset, + ref handled); + + // If handled, ignore the original call + if (handled) + { + Log.Verbose("[FlyText] FlyText was handled."); + + // Returning null to AddFlyText from CreateFlyText will result + // in the operation being dropped entirely. + return IntPtr.Zero; + } + + // Check if any values have changed + var dirty = tmpKind != (FlyTextKind)kind || + tmpVal1 != val1 || + tmpVal2 != val2 || + tmpText1.ToString() != cmpText1 || + tmpText2.ToString() != cmpText2 || + tmpColor != color || + tmpIcon != icon || + Math.Abs(tmpYOffset - yOffset) > float.Epsilon; + + // If not dirty, make the original call + if (!dirty) + { + Log.Verbose("[FlyText] Calling flytext with original args."); + return this.createFlyTextHook.Original(addonFlyText, kind, val1, val2, text2, color, icon, text1, yOffset); + } + + var terminated1 = Terminate(tmpText1.Encode()); + var terminated2 = Terminate(tmpText2.Encode()); + var pText1 = Marshal.AllocHGlobal(terminated1.Length); + var pText2 = Marshal.AllocHGlobal(terminated2.Length); + Marshal.Copy(terminated1, 0, pText1, terminated1.Length); + Marshal.Copy(terminated2, 0, pText2, terminated2.Length); + Log.Verbose("[FlyText] Allocated and set strings."); + + retVal = this.createFlyTextHook.Original( + addonFlyText, + (int)tmpKind, + tmpVal1, + tmpVal2, + pText2, + tmpColor, + tmpIcon, + pText1, + tmpYOffset); + + Log.Verbose("[FlyText] Returned from original. Delaying free task."); + + Task.Delay(2000).ContinueWith(_ => + { + try + { + Marshal.FreeHGlobal(pText1); + Marshal.FreeHGlobal(pText2); + Log.Verbose("[FlyText] Freed strings."); + } + catch (Exception e) + { + Log.Verbose(e, "[FlyText] Exception occurred freeing strings in task."); + } + }); + this.hookTimer.Stop(); + Log.Verbose($"[FlyText] Hook took {this.hookTimer.ElapsedTicks} ticks, {this.hookTimer.ElapsedMilliseconds}ms."); + } + catch (Exception e) + { + Log.Error(e, "Exception occurred in CreateFlyTextDetour!"); + } + + return retVal; + } + + private static byte[] Terminate(byte[] source) + { + var terminated = new byte[source.Length + 1]; + Array.Copy(source, 0, terminated, 0, source.Length); + terminated[^1] = 0; + + return terminated; + } + } +} diff --git a/Dalamud/Game/Internal/Gui/FlyTextGuiAddressResolver.cs b/Dalamud/Game/Internal/Gui/FlyTextGuiAddressResolver.cs new file mode 100644 index 000000000..6d073b701 --- /dev/null +++ b/Dalamud/Game/Internal/Gui/FlyTextGuiAddressResolver.cs @@ -0,0 +1,32 @@ +using System; + +namespace Dalamud.Game.Internal.Gui +{ + /// + /// An address resolver for the class. + /// + public class FlyTextGuiAddressResolver : BaseAddressResolver + { + /// + /// Gets the address of the native AddFlyText method, which occurs + /// when the game adds fly text elements to the UI. Multiple fly text + /// elements can be added in a single AddFlyText call. + /// + public IntPtr AddFlyText { get; private set; } + + /// + /// Gets the address of the native CreateFlyText method, which occurs + /// when the game creates a new fly text element. This method is called + /// once per fly text element, and can be called multiple times per + /// AddFlyText call. + /// + public IntPtr CreateFlyText { get; private set; } + + /// + protected override void Setup64Bit(SigScanner sig) + { + this.AddFlyText = sig.ScanText("E8 ?? ?? ?? ?? FF C7 41 D1 C7"); + this.CreateFlyText = sig.ScanText("48 89 74 24 ?? 48 89 7C 24 ?? 41 56 48 83 EC 40 48 63 FA"); + } + } +} diff --git a/Dalamud/Game/Internal/Gui/FlyTextKind.cs b/Dalamud/Game/Internal/Gui/FlyTextKind.cs new file mode 100644 index 000000000..b9a222ac3 --- /dev/null +++ b/Dalamud/Game/Internal/Gui/FlyTextKind.cs @@ -0,0 +1,214 @@ +namespace Dalamud.Game.Internal.Gui +{ + /// + /// Enum of FlyTextKind values. Members suffixed with + /// a number seem to be a duplicate, or perform duplicate behavior. + /// + public enum FlyTextKind + { + /// + /// Val1 in serif font, Text2 in sans-serif as subtitle. + /// Used for autos and incoming DoTs. + /// + AutoAttack = 0, + + /// + /// Val1 in serif font, Text2 in sans-serif as subtitle. + /// Does a bounce effect on appearance. + /// + DirectHit = 1, + + /// + /// Val1 in larger serif font with exclamation, with Text2 + /// in sans-serif as subtitle. Does a bigger bounce effect on appearance. + /// + CriticalHit = 2, + + /// + /// Val1 in even larger serif font with 2 exclamations, Text2 in + /// sans-serif as subtitle. Does a large bounce effect on appearance. + /// Does not scroll up or down the screen. + /// + CriticalDirectHit = 3, + + /// + /// AutoAttack with sans-serif Text1 to the left of the Val1. + /// + NamedAttack = 4, + + /// + /// DirectHit with sans-serif Text1 to the left of the Val1. + /// + NamedDirectHit = 5, + + /// + /// CriticalHit with sans-serif Text1 to the left of the Val1. + /// + NamedCriticalHit = 6, + + /// + /// CriticalDirectHit with sans-serif Text1 to the left of the Val1. + /// + NamedCriticalDirectHit = 7, + + /// + /// All caps, serif MISS. + /// + Miss = 8, + + /// + /// Sans-serif Text1 next to all caps serif MISS. + /// + NamedMiss = 9, + + /// + /// All caps serif DODGE. + /// + Dodge = 10, + + /// + /// Sans-serif Text1 next to all caps serif DODGE. + /// + NamedDodge = 11, + + /// + /// Icon next to sans-serif Text1. + /// + NamedIcon = 12, + NamedIcon2 = 13, + + /// + /// Serif Val1 with all caps condensed font EXP with Text2 in sans-serif as subtitle. + /// + Exp = 14, + + /// + /// Sans-serif Text1 next to serif Val1 with all caps condensed font MP with Text2 in sans-serif as subtitle. + /// + NamedMp = 15, + + /// + /// Sans-serif Text1 next to serif Val1 with all caps condensed font TP with Text2 in sans-serif as subtitle. + /// + NamedTp = 16, + + NamedAttack2 = 17, + NamedMp2 = 18, + NamedTp2 = 19, + + /// + /// Sans-serif Text1 next to serif Val1 with all caps condensed font EP with Text2 in sans-serif as subtitle. + /// + NamedEp = 20, + + /// + /// Displays nothing. + /// + None = 21, + + /// + /// All caps serif INVULNERABLE. + /// + Invulnerable = 22, + + /// + /// All caps sans-serif condensed font INTERRUPTED! + /// Does a large bounce effect on appearance. + /// Does not scroll up or down the screen. + /// + Interrupted = 23, + + /// + /// AutoAttack with no Text2. + /// + AutoAttackNoText = 24, + AutoAttackNoText2 = 25, + CriticalHit2 = 26, + AutoAttackNoText3 = 27, + NamedCriticalHit2 = 28, + + /// + /// Same as NamedCriticalHit with a green (cannot change) MP in condensed font to the right of Val1. + /// Does a jiggle effect to the right on appearance. + /// + NamedCriticalHitWithMp = 29, + + /// + /// Same as NamedCriticalHit with a yellow (cannot change) TP in condensed font to the right of Val1. + /// Does a jiggle effect to the right on appearance. + /// + NamedCriticalHitWithTp = 30, + + /// + /// Same as NamedIcon with sans-serif "has no effect!" to the right. + /// + NamedIconHasNoEffect = 31, + + /// + /// Same as NamedIcon but Text1 is slightly faded. Used for buff expiration. + /// + NamedIconFaded = 32, + NamedIconFaded2 = 33, + + /// + /// Text1 in sans-serif font. + /// + Named = 34, + + /// + /// Same as NamedIcon with sans-serif "(fully resisted)" to the right. + /// + NamedIconFullyResisted = 35, + + /// + /// All caps serif 'INCAPACITATED!'. + /// + Incapacitated = 36, + + /// + /// Text1 with sans-serif "(fully resisted)" to the right. + /// + NamedFullyResisted = 37, + + /// + /// Text1 with sans-serif "has no effect!" to the right. + /// + NamedHasNoEffect = 38, + + NamedAttack3 = 39, + NamedMp3 = 40, + NamedTp3 = 41, + + /// + /// Same as NamedIcon with serif "INVULNERABLE!" beneath the Text1. + /// + NamedIconInvulnerable = 42, + + /// + /// All caps serif RESIST. + /// + Resist = 43, + + /// + /// Same as NamedIcon but places the given icon in the item icon outline. + /// + NamedIconWithItemOutline = 44, + + AutoAttackNoText4 = 45, + CriticalHit3 = 46, + + /// + /// All caps serif REFLECT. + /// + Reflect = 47, + + /// + /// All caps serif REFLECTED. + /// + Reflected = 48, + + DirectHit2 = 49, + CriticalHit5 = 50, + CriticalDirectHit2 = 51, + } +} diff --git a/Dalamud/Game/Internal/Gui/GameGui.cs b/Dalamud/Game/Internal/Gui/GameGui.cs index f27f75662..005a15da9 100644 --- a/Dalamud/Game/Internal/Gui/GameGui.cs +++ b/Dalamud/Game/Internal/Gui/GameGui.cs @@ -66,6 +66,7 @@ namespace Dalamud.Game.Internal.Gui this.Chat = new ChatGui(this.address.ChatManager, scanner, dalamud); this.PartyFinder = new PartyFinderGui(scanner, dalamud); this.Toast = new ToastGui(scanner, dalamud); + this.FlyText = new FlyTextGui(scanner, dalamud); this.setGlobalBgmHook = new Hook(this.address.SetGlobalBgm, this.HandleSetGlobalBgmDetour); this.handleItemHoverHook = new Hook(this.address.HandleItemHover, this.HandleItemHoverDetour); @@ -161,6 +162,11 @@ namespace Dalamud.Game.Internal.Gui /// public ToastGui Toast { get; private set; } + /// + /// Gets the instance. + /// + public FlyTextGui FlyText { get; private set; } + /// /// Gets a value indicating whether the game UI is hidden. /// @@ -452,6 +458,7 @@ namespace Dalamud.Game.Internal.Gui { this.Chat.Enable(); this.Toast.Enable(); + this.FlyText.Enable(); this.PartyFinder.Enable(); this.setGlobalBgmHook.Enable(); this.handleItemHoverHook.Enable(); @@ -468,6 +475,7 @@ namespace Dalamud.Game.Internal.Gui { this.Chat.Dispose(); this.Toast.Dispose(); + this.FlyText.Dispose(); this.PartyFinder.Dispose(); this.setGlobalBgmHook.Dispose(); this.handleItemHoverHook.Dispose(); diff --git a/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs b/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs index ad805f528..ed9437d3d 100644 --- a/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs +++ b/Dalamud/Game/Text/SeStringHandling/SeStringManager.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using Dalamud.Data; using Dalamud.Game.Text.SeStringHandling.Payloads; @@ -47,6 +49,24 @@ namespace Dalamud.Game.Text.SeStringHandling return new SeString(payloads); } + /// + /// Parse a binary game message into an SeString. + /// + /// Pointer to a binary message payload data in SE's internal format. + /// An SeString containing parsed Payload objects for each payload in the data. + public SeString Parse(IntPtr bytePtr) + { + var bytes = new List(); + byte read = Marshal.ReadByte(bytePtr, 0); + for (int ofs = 1; read != 0; ofs++) + { + bytes.Add(read); + read = Marshal.ReadByte(bytePtr, ofs); + } + + return this.Parse(bytes.ToArray()); + } + /// /// Creates an SeString representing an entire Payload chain that can be used to link an item in the chat log. /// diff --git a/Dalamud/Interface/Internal/Windows/DataWindow.cs b/Dalamud/Interface/Internal/Windows/DataWindow.cs index 6b723098f..c2ad77d1b 100644 --- a/Dalamud/Interface/Internal/Windows/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/DataWindow.cs @@ -9,6 +9,7 @@ using Dalamud.Game.ClientState.Actors.Types; using Dalamud.Game.ClientState.Actors.Types.NonPlayer; using Dalamud.Game.ClientState.Structs.JobGauge; using Dalamud.Game.Internal; +using Dalamud.Game.Internal.Gui; using Dalamud.Game.Internal.Gui.Addon; using Dalamud.Game.Internal.Gui.Toast; using Dalamud.Game.Text; @@ -49,6 +50,7 @@ namespace Dalamud.Interface.Internal.Windows private UIDebug addonInspector = null; + // Toast fields private string inputTextToast = string.Empty; private int toastPosition = 0; private int toastSpeed = 0; @@ -57,6 +59,17 @@ namespace Dalamud.Interface.Internal.Windows private int questToastIconId = 0; private bool questToastCheckmark = false; + // Fly text fields + private int flyActor; + private FlyTextKind flyKind; + private int flyVal1; + private int flyVal2; + private string flyText1 = string.Empty; + private string flyText2 = string.Empty; + private int flyIcon; + private Vector4 flyColor = new(1, 0, 0, 1); + + // ImGui fields private string inputTexPath = string.Empty; private TextureWrap debugTex = null; private Vector2 inputTexUv0 = Vector2.Zero; @@ -98,6 +111,7 @@ namespace Dalamud.Interface.Internal.Windows StartInfo, Target, Toast, + FlyText, ImGui, Tex, Gamepad, @@ -226,6 +240,10 @@ namespace Dalamud.Interface.Internal.Windows this.DrawToast(); break; + case DataKind.FlyText: + this.DrawFlyText(); + break; + case DataKind.ImGui: this.DrawImGui(); break; @@ -687,6 +705,45 @@ namespace Dalamud.Interface.Internal.Windows } } + private void DrawFlyText() + { + if (ImGui.BeginCombo("Kind", this.flyKind.ToString())) + { + var names = Enum.GetNames(typeof(FlyTextKind)); + for (int i = 0; i < names.Length; i++) + { + if (ImGui.Selectable($"{names[i]} ({i})")) + this.flyKind = (FlyTextKind)i; + } + + ImGui.EndCombo(); + } + + ImGui.InputText("Text1", ref this.flyText1, 200); + ImGui.InputText("Text2", ref this.flyText2, 200); + + ImGui.InputInt("Val1", ref this.flyVal1); + ImGui.InputInt("Val2", ref this.flyVal2); + + ImGui.InputInt("Icon ID", ref this.flyIcon); + ImGui.ColorEdit4("Color", ref this.flyColor); + ImGui.InputInt("Actor Index", ref this.flyActor); + var sendColor = ImGui.ColorConvertFloat4ToU32(this.flyColor); + + if (ImGui.Button("Send")) + { + this.dalamud.Framework.Gui.FlyText.AddFlyText( + this.flyKind, + unchecked((uint)this.flyActor), + unchecked((uint)this.flyVal1), + unchecked((uint)this.flyVal2), + this.flyText1, + this.flyText2, + sendColor, + unchecked((uint)this.flyIcon)); + } + } + private void DrawImGui() { ImGui.Text("Monitor count: " + ImGui.GetPlatformIO().Monitors.Size);