From a776358b96204610dc2ebf4849bf834754428ae2 Mon Sep 17 00:00:00 2001 From: Haselnussbomber Date: Mon, 4 Aug 2025 03:13:45 +0200 Subject: [PATCH] Completion rewrite (#2305) --- Dalamud/Game/Internal/Completion.cs | 322 --------------------- Dalamud/Game/Internal/DalamudCompletion.cs | 279 ++++++++++++++++++ 2 files changed, 279 insertions(+), 322 deletions(-) delete mode 100644 Dalamud/Game/Internal/Completion.cs create mode 100644 Dalamud/Game/Internal/DalamudCompletion.cs diff --git a/Dalamud/Game/Internal/Completion.cs b/Dalamud/Game/Internal/Completion.cs deleted file mode 100644 index 01c9c99c5..000000000 --- a/Dalamud/Game/Internal/Completion.cs +++ /dev/null @@ -1,322 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; - -using Dalamud.Game.Command; -using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Hooking; -using Dalamud.Plugin.Services; -using Dalamud.Utility; - -using FFXIVClientStructs.FFXIV.Client.System.String; -using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Component.Completion; -using FFXIVClientStructs.FFXIV.Component.GUI; - -namespace Dalamud.Game.Internal; - -/// -/// This class adds dalamud and plugin commands to the chat box's autocompletion. -/// -[ServiceManager.EarlyLoadedService] -internal sealed unsafe class Completion : IInternalDisposableService -{ - // 0xFF is a magic group number that causes CompletionModule's internals to treat entries - // as raw strings instead of as lookups into an EXD sheet - private const int GroupNumber = 0xFF; - - [ServiceManager.ServiceDependency] - private readonly CommandManager commandManager = Service.Get(); - - [ServiceManager.ServiceDependency] - private readonly Framework framework = Service.Get(); - - private readonly Dictionary cachedCommands = []; - private readonly ConcurrentQueue addedCommands = []; - - private EntryStrings? dalamudCategory; - - private Hook? getSelection; - - // This is marked volatile since we set and check it from different threads. Instead of using a synchronization - // primitive, a volatile is sufficient since the absolute worst case is that we delay one extra frame to reset - // the list, which is fine - private volatile bool needsClear; - private bool disposed; - private nint wantedVtblPtr; - - /// - /// Initializes a new instance of the class. - /// - [ServiceManager.ServiceConstructor] - internal Completion() - { - this.commandManager.CommandAdded += this.OnCommandAdded; - this.commandManager.CommandRemoved += this.OnCommandRemoved; - - this.framework.Update += this.OnUpdate; - } - - /// Finalizes an instance of the class. - ~Completion() => this.Dispose(false); - - /// - void IInternalDisposableService.DisposeService() => this.Dispose(true); - - private static AtkUnitBase* FindOwningAddon(AtkComponentTextInput* component) - { - if (component == null) return null; - - var node = (AtkResNode*)component->OwnerNode; - if (node == null) return null; - - while (node->ParentNode != null) - node = node->ParentNode; - - foreach (var addon in RaptureAtkUnitManager.Instance()->AllLoadedUnitsList.Entries) - { - if (addon.Value->RootNode == node) - return addon; - } - - return null; - } - - private AtkComponentTextInput* GetActiveTextInput() - { - var mod = RaptureAtkModule.Instance(); - if (mod == null) return null; - - var basePtr = mod->TextInput.TargetTextInputEventInterface; - if (basePtr == null) return null; - - // Once CS has an implementation for multiple inheritance, we can remove this sig from dalamud - // as well as the nasty pointer arithmetic below. But for now, we need to do this manually. - // The AtkTextInputEventInterface* is the secondary base class for AtkComponentTextInput* - // so the pointer is sizeof(AtkComponentInputBase) into the object. We verify that we're looking - // at the object we think we are by confirming the pointed-to vtbl matches the known secondary vtbl for - // AtkComponentTextInput, and if it does, we can shift the pointer back to get the start of our text input - if (this.wantedVtblPtr == 0) - { - this.wantedVtblPtr = - Service.Get().GetStaticAddressFromSig( - "48 89 01 48 8D 05 ?? ?? ?? ?? 48 89 81 ?? ?? ?? ?? 48 8D 05 ?? ?? ?? ?? 48 89 81 ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 8B 48 68", - 4); - } - - var vtblPtr = *(nint*)basePtr; - if (vtblPtr != this.wantedVtblPtr) return null; - - // This needs to be updated if the layout/base order of AtkComponentTextInput changes - return (AtkComponentTextInput*)((AtkComponentInputBase*)basePtr - 1); - } - - private bool AllowCompletion(string cmd) - { - // this is one of our commands, let's see if we should allow this to be completed - var component = this.GetActiveTextInput(); - - // ContainingAddon or ContainingAddon2 aren't always populated, but they - // seem to be in any case where this is actually a completable AtkComponentTextInput - // In the worst case, we can walk the AtkNode tree- but let's try the easy pointers first - var addon = component->ContainingAddon; - if (addon == null) addon = component->ContainingAddon2; - if (addon == null) addon = FindOwningAddon(component); - - if (addon == null || addon->NameString != "ChatLog") - { - // we don't know what addon is completing, or we know it isn't ChatLog - // either way, we should just reject this completion - return false; - } - - // We're in ChatLog, so check if this is the start of the text input - // AtkComponentTextInput->UnkText1 is the evaluated version of the current text - // so if the command starts with that, then either it's empty or a prefix completion. - // In either case, we're happy to allow completion. - return cmd.StartsWith(component->UnkText1.StringPtr.ExtractText()); - } - - private void Dispose(bool disposing) - { - if (this.disposed) - return; - - if (disposing) - { - this.getSelection?.Disable(); - this.getSelection?.Dispose(); - this.framework.Update -= this.OnUpdate; - this.commandManager.CommandAdded -= this.OnCommandAdded; - this.commandManager.CommandRemoved -= this.OnCommandRemoved; - - this.dalamudCategory?.Dispose(); - this.ClearCachedCommands(); - } - - this.disposed = true; - } - - private void OnCommandAdded(object? sender, CommandManager.CommandEventArgs e) - { - if (e.CommandInfo.ShowInHelp) - this.addedCommands.Enqueue(e.Command); - } - - private void OnCommandRemoved(object? sender, CommandManager.CommandEventArgs e) => this.needsClear = true; - - private void OnUpdate(IFramework fw) - { - var atkModule = RaptureAtkModule.Instance(); - if (atkModule == null) return; - - var textInput = &atkModule->TextInput; - - if (textInput->CompletionModule == null) return; - - // Before we change _anything_ we need to check the state of the UI- if the completion list is open - // changes to the underlying data are extremely unsafe, so we'll just wait until the next frame - // worst case, someone tries to complete a command that _just_ got unloaded so it won't do anything - // but that's the same as making a typo, really - if (textInput->CompletionDepth > 0) return; - - // Create the category for Dalamud commands. - // This needs to be done here, since we cannot create Utf8Strings before the game - // has initialized (no allocator set up yet). - this.dalamudCategory ??= new EntryStrings("【Dalamud】"); - - this.LoadCommands(textInput->CompletionModule); - } - - private CategoryData* EnsureCategoryData(CompletionModule* module) - { - if (module == null) return null; - - if (this.getSelection == null) - { - this.getSelection = Hook.FromAddress( - (IntPtr)module->VirtualTable->GetSelection, - this.GetSelectionDetour); - this.getSelection.Enable(); - } - - for (var i = 0; i < module->CategoryNames.Count; i++) - { - if (module->CategoryNames[i].AsReadOnlySeStringSpan().ContainsText("【Dalamud】"u8)) - { - return module->CategoryData[i]; - } - } - - // Create the category since we don't have one - var categoryData = (CategoryData*)Memory.MemoryHelper.GameAllocateDefault((ulong)sizeof(CategoryData)); - categoryData->Ctor(GroupNumber, 0xFF); - module->AddCategoryData(GroupNumber, this.dalamudCategory!.Display->StringPtr, - this.dalamudCategory.Match->StringPtr, categoryData); - - return categoryData; - } - - private void ClearCachedCommands() - { - if (this.cachedCommands.Count == 0) - return; - - foreach (var entry in this.cachedCommands.Values) - { - entry.Dispose(); - } - - this.cachedCommands.Clear(); - } - - private void LoadCommands(CompletionModule* completionModule) - { - if (completionModule == null) return; - if (completionModule->CategoryNames.Count == 0) return; // We want this data populated first - - if (this.needsClear && this.cachedCommands.Count > 0) - { - this.needsClear = false; - completionModule->ClearCompletionData(); - this.ClearCachedCommands(); - return; - } - - var catData = this.EnsureCategoryData(completionModule); - if (catData == null) return; - - if (catData->CompletionData.Count == 0) - { - var inputCommands = this.commandManager.Commands.Where(pair => pair.Value.ShowInHelp); - foreach (var (cmd, _) in inputCommands) - AddEntry(cmd); - catData->SortEntries(); - - return; - } - - var needsSort = false; - while (this.addedCommands.TryDequeue(out var cmd)) - { - needsSort = true; - AddEntry(cmd); - } - - if (needsSort) - catData->SortEntries(); - - return; - - void AddEntry(string cmd) - { - if (this.cachedCommands.ContainsKey(cmd)) return; - - var cmdStr = new EntryStrings(cmd); - this.cachedCommands.Add(cmd, cmdStr); - completionModule->AddCompletionEntry( - GroupNumber, - 0xFF, - cmdStr.Display->StringPtr, - cmdStr.Match->StringPtr, - 0xFF); - } - } - - private int GetSelectionDetour(CompletionModule* thisPtr, CategoryData.CompletionDataStruct* dataStructs, int index, Utf8String* outputString, Utf8String* outputDisplayString) - { - var ret = this.getSelection!.Original.Invoke(thisPtr, dataStructs, index, outputString, outputDisplayString); - if (ret != -2 || outputString == null) return ret; - - // -2 means it was a plain text final selection, so it might be ours - // Unfortunately, the code that uses this string mangles the color macro for some reason... - // We'll just strip those out since we don't need the color in the chatbox - var txt = outputString->StringPtr.ExtractText(); - if (!this.cachedCommands.ContainsKey(txt)) - return ret; - - if (!this.AllowCompletion(txt)) - { - outputString->Clear(); - if (outputDisplayString != null) outputDisplayString->Clear(); - return ret; - } - - outputString->SetString(txt + " "); - return ret; - } - - private class EntryStrings(string command) : IDisposable - { - public Utf8String* Display { get; } = - Utf8String.FromSequence(new SeStringBuilder().AddUiForeground(command, 539).BuiltString.EncodeWithNullTerminator()); - - public Utf8String* Match { get; } = Utf8String.FromString(command); - - public void Dispose() - { - this.Display->Dtor(true); - this.Match->Dtor(true); - } - } -} diff --git a/Dalamud/Game/Internal/DalamudCompletion.cs b/Dalamud/Game/Internal/DalamudCompletion.cs new file mode 100644 index 000000000..ec5652b3c --- /dev/null +++ b/Dalamud/Game/Internal/DalamudCompletion.cs @@ -0,0 +1,279 @@ +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Game.Command; +using Dalamud.Hooking; +using Dalamud.Utility; + +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.Completion; +using FFXIVClientStructs.FFXIV.Component.GUI; + +using Lumina.Text; + +namespace Dalamud.Game.Internal; + +/// +/// This class adds Dalamud and plugin commands to the chat box's autocompletion. +/// +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class DalamudCompletion : IInternalDisposableService +{ + // 0xFF is a magic group number that causes CompletionModule's internals to treat entries + // as raw strings instead of as lookups into an EXD sheet + private const int GroupNumber = 0xFF; + + [ServiceManager.ServiceDependency] + private readonly CommandManager commandManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + + private readonly Dictionary cachedCommands = []; + + private EntryStrings? dalamudCategory; + + private Hook openSuggestionsHook; + private Hook? getSelectionHook; + + /// + /// Initializes a new instance of the class. + /// + [ServiceManager.ServiceConstructor] + internal DalamudCompletion() + { + this.framework.RunOnTick(this.Setup); + } + + /// + void IInternalDisposableService.DisposeService() + { + this.openSuggestionsHook?.Disable(); + this.openSuggestionsHook?.Dispose(); + + this.getSelectionHook?.Disable(); + this.getSelectionHook?.Dispose(); + + this.dalamudCategory?.Dispose(); + + this.ClearCachedCommands(); + } + + private void Setup() + { + var uiModule = UIModule.Instance(); + if (uiModule == null || uiModule->FrameCount == 0) + { + this.framework.RunOnTick(this.Setup); + return; + } + + this.dalamudCategory = new EntryStrings("【Dalamud】"); + + this.openSuggestionsHook = Hook.FromAddress( + (nint)AtkTextInput.MemberFunctionPointers.OpenCompletion, + this.OpenSuggestionsDetour); + + this.getSelectionHook = Hook.FromAddress( + (nint)uiModule->CompletionModule.VirtualTable->GetSelection, + this.GetSelectionDetour); + + this.openSuggestionsHook.Enable(); + this.getSelectionHook.Enable(); + } + + private void OpenSuggestionsDetour(AtkTextInput* thisPtr) + { + this.UpdateCompletionData(); + this.openSuggestionsHook!.Original(thisPtr); + } + + private int GetSelectionDetour(CompletionModule* thisPtr, CategoryData.CompletionDataStruct* dataStructs, int index, Utf8String* outputString, Utf8String* outputDisplayString) + { + var ret = this.getSelectionHook!.Original.Invoke(thisPtr, dataStructs, index, outputString, outputDisplayString); + this.HandleInsert(ret, outputString, outputDisplayString); + return ret; + } + + private void UpdateCompletionData() + { + if (!this.TryGetActiveTextInput(out var component, out var addon)) + { + if (this.HasDalamudCategory()) + this.ResetCompletionData(); + + return; + } + + var uiModule = UIModule.Instance(); + if (uiModule == null) + return; + + this.ResetCompletionData(); + this.ClearCachedCommands(); + + var currentText = component->UnkText1.StringPtr.ExtractText(); + + var commands = this.commandManager.Commands + .Where(kv => kv.Value.ShowInHelp && (currentText.Length == 0 || kv.Key.StartsWith(currentText))) + .OrderBy(kv => kv.Key); + + if (!commands.Any()) + return; + + var categoryData = (CategoryData*)IMemorySpace.GetDefaultSpace()->Malloc((ulong)sizeof(CategoryData), 0x08); + categoryData->Ctor(GroupNumber, 0xFF); + + uiModule->CompletionModule.AddCategoryData( + GroupNumber, + this.dalamudCategory!.Display->StringPtr, + this.dalamudCategory.Match->StringPtr, categoryData); + + foreach (var (cmd, info) in commands) + { + if (!this.cachedCommands.TryGetValue(cmd, out var entryString)) + this.cachedCommands.Add(cmd, entryString = new EntryStrings(cmd)); + + uiModule->CompletionModule.AddCompletionEntry( + GroupNumber, + 0xFF, + entryString.Display->StringPtr, + entryString.Match->StringPtr, + 0xFF); + } + + categoryData->SortEntries(); + } + + private void HandleInsert(int ret, Utf8String* outputString, Utf8String* outputDisplayString) + { + // -2 means it was a plain text final selection, so it might be ours. + if (ret != -2 || outputString == null) + return; + + // Strip out color payloads that we added to the string. + var txt = outputString->StringPtr.ExtractText(); + if (!this.cachedCommands.ContainsKey(txt)) + return; + + if (!this.TryGetActiveTextInput(out _, out _)) + { + outputString->Clear(); + + if (outputDisplayString != null) + outputDisplayString->Clear(); + + return; + } + + outputString->SetString(txt + ' '); + } + + private bool TryGetActiveTextInput(out AtkComponentTextInput* component, out AtkUnitBase* addon) + { + component = null; + addon = null; + + var raptureAtkModule = RaptureAtkModule.Instance(); + if (raptureAtkModule == null) + return false; + + var textInputEventInterface = raptureAtkModule->TextInput.TargetTextInputEventInterface; + if (textInputEventInterface == null) + return false; + + var ownerNode = textInputEventInterface->GetOwnerNode(); + if (ownerNode == null || ownerNode->GetNodeType() != NodeType.Component) + return false; + + var componentNode = (AtkComponentNode*)ownerNode; + var componentBase = componentNode->Component; + if (componentBase == null || componentBase->GetComponentType() != ComponentType.TextInput) + return false; + + component = (AtkComponentTextInput*)componentBase; + + addon = component->ContainingAddon; + + if (addon == null) + addon = component->ContainingAddon2; + + if (addon == null) + addon = RaptureAtkUnitManager.Instance()->GetAddonByNode((AtkResNode*)component->OwnerNode); + + return addon != null && addon->NameString == "ChatLog"; + } + + private bool HasDalamudCategory() + { + var uiModule = UIModule.Instance(); + if (uiModule == null) + return false; + + for (var i = 0; i < uiModule->CompletionModule.CategoryNames.Count; i++) + { + if (uiModule->CompletionModule.CategoryNames[i].AsReadOnlySeStringSpan().ContainsText("【Dalamud】"u8)) + { + return true; + } + } + + return false; + } + + private void ResetCompletionData() + { + var uiModule = UIModule.Instance(); + if (uiModule == null) + return; + + uiModule->CompletionModule.ClearCompletionData(); + + // This happens in UIModule.Update. Just repeat it to fill CompletionData back up with defaults. + uiModule->CompletionModule.Update( + &uiModule->CompletionSheetName, + &uiModule->CompletionOpenIconMacro, + &uiModule->CompletionCloseIconMacro, + 0); + } + + private void ClearCachedCommands() + { + foreach (var entry in this.cachedCommands.Values) + { + entry.Dispose(); + } + + this.cachedCommands.Clear(); + } + + private class EntryStrings : IDisposable + { + public EntryStrings(string command) + { + var rssb = SeStringBuilder.SharedPool.Get(); + + this.Display = Utf8String.FromSequence(rssb + .PushColorType(539) + .Append(command) + .PopColorType() + .GetViewAsSpan()); + + SeStringBuilder.SharedPool.Return(rssb); + + this.Match = Utf8String.FromString(command); + } + + public Utf8String* Display { get; } + + public Utf8String* Match { get; } + + public void Dispose() + { + this.Display->Dtor(true); + this.Match->Dtor(true); + } + } +}