diff --git a/Tweaks/UiAdjustment/FastSearch.cs b/Tweaks/UiAdjustment/FastSearch.cs new file mode 100644 index 00000000..de945dc4 --- /dev/null +++ b/Tweaks/UiAdjustment/FastSearch.cs @@ -0,0 +1,232 @@ +using Dalamud.Utility; +using FFXIVClientStructs.Attributes; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.STD; +using Lumina.Excel.GeneratedSheets; +using SimpleTweaksPlugin.TweakSystem; +using SimpleTweaksPlugin.Utility; +using System; +using System.Linq; +using System.Runtime.InteropServices; +using Dalamud.Utility.Signatures; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using FFXIVClientStructs.Interop.Attributes; +using Dalamud.Memory; +using LuminaAddon = Lumina.Excel.GeneratedSheets.Addon; +using Addon = FFXIVClientStructs.Attributes.Addon; +using System.Text; +using SimpleTweaksPlugin.Events; + +namespace SimpleTweaksPlugin.Tweaks.UiAdjustment; + +[TweakName("Fast Item Search")] +[TweakDescription("Enable superfast searches for the market board & crafting log.")] +[TweakAuthor("Asriel")] +[TweakAutoConfig] +[TweakReleaseVersion(UnreleasedVersion)] +public unsafe class FastSearch : UiAdjustments.SubTweak { + public class FastSearchConfig : TweakConfig { + [TweakConfigOption("Use Fuzzy Search")] + public bool UseFuzzySearch; + } + + public FastSearchConfig Config { get; private set; } + + [StructLayout(LayoutKind.Explicit, Size = 0x18)] + public struct SearchContextVTable { + [FieldOffset(0x00)] public delegate* unmanaged Destroy; + [FieldOffset(0x08)] public delegate* unmanaged IsComplete; + [FieldOffset(0x10)] public delegate* unmanaged Iterate; + } + + [StructLayout(LayoutKind.Explicit, Size = 0xA0)] + public struct SearchContextData { + [FieldOffset(0x30)] public StdDeque> Results; + [FieldOffset(0x70)] public nint Callback2; + [FieldOffset(0x78)] public nint Callback; + } + + [StructLayout(LayoutKind.Explicit, Size = 0x260)] + public struct SearchContext { + [FieldOffset(0x00)] public SearchContextVTable* VTable; + [FieldOffset(0xE0)] public bool IsExact; + [FieldOffset(0xE1)] public bool IsComplete; + [FieldOffset(0xE2)] public bool CanIterate; + [FieldOffset(0xE4)] public uint RowEndIdx; + [FieldOffset(0xE8)] public uint RowStartIdx; + [FieldOffset(0xF0)] public SearchContextData CtxData; + [FieldOffset(0x190)] public SearchContextData CtxData2; + [FieldOffset(0x258)] public StdVector* VectorPtr; + } + + [Agent(AgentId.RecipeNote)] + [StructLayout(LayoutKind.Explicit, Size = 0x568)] + public unsafe partial struct AgentRecipeNote2 { + [FieldOffset(0x0)] public AgentInterface AgentInterface; + + [FieldOffset(0x3BC)] public int SelectedRecipeIndex; + [FieldOffset(0x3D4)] public uint ActiveCraftRecipeId; // 0 when not actively crafting, does not include 0x10_000 + [FieldOffset(0x3EC)] public bool RecipeSearchOpen; + [FieldOffset(0x406)] public bool RecipeSearchProcessing; + [FieldOffset(0x408)] public Utf8String RecipeSearch; + + [FieldOffset(0x478)] public SearchContext* SearchContext; + [FieldOffset(0x480)] public StdVector SearchResults; + + [FieldOffset(0x498)] public byte RecipeSearchHistorySelected; + [FieldOffset(0x4A0)] public StdDeque RecipeSearchHistory; + } + + [Agent(AgentId.ItemSearch)] + [StructLayout(LayoutKind.Explicit, Size = 0x37F0)] + public partial struct AgentItemSearch2 + { + [StructLayout(LayoutKind.Explicit, Size = 0x98)] + public struct StringHolder + { + [FieldOffset(0x10)] public int Unk90Size; + [FieldOffset(0x28)] public Utf8String SearchParam; + [FieldOffset(0x90)] public nint Unk90Ptr; + } + + [FieldOffset(0x0)] public AgentInterface AgentInterface; + [FieldOffset(0x98)] public StringHolder* StringData; + [FieldOffset(0x3304)] public uint ResultItemID; + [FieldOffset(0x330C)] public uint ResultSelectedIndex; + [FieldOffset(0x331C)] public uint ResultHoveredIndex; + [FieldOffset(0x3324)] public uint ResultHoveredCount; + [FieldOffset(0x332C)] public byte ResultHoveredHQ; + [FieldOffset(0x37D0)] public uint* ItemBuffer; + [FieldOffset(0x37D8)] public uint ItemCount; + [FieldOffset(0x37E4)] public bool IsPartialSearching; + [FieldOffset(0x37E5)] public bool IsItemPushPending; + } + + [Addon("ItemSearch")] + [StructLayout(LayoutKind.Explicit, Size = 0x33E0)] + public partial struct AddonItemSearch + { + [FieldOffset(0x0)] public AtkUnitBase Base; + [FieldOffset(0x190)] public bool IsPartialSearchEnabled; + [FieldOffset(0x238)] public Utf8String String238; + [FieldOffset(0x2A0)] public Utf8String String2A0; + [FieldOffset(0x308)] public Utf8String String308; + [FieldOffset(0x370)] public Utf8String String370; + [FieldOffset(0x3D8)] public Utf8String String3D8; + [FieldOffset(0x440)] public Utf8String String440; + [FixedSizeArray(96)] + [FieldOffset(0x4A8)] public fixed byte StringArray[96 * 0x68]; // 96 * Utf8String + [FieldOffset(0x3210)] public AtkComponentCheckBox* PartialSearchCheckBox; + [FieldOffset(0x3218)] public AtkTextNode* SearchPanelTitle; + } + + private delegate void RecipeNoteRecieveDelegate(AgentRecipeNote2* a1, Utf8String* a2, bool a3, bool a4); + [TweakHook, Signature("48 89 5C 24 ?? 48 89 6C 24 ?? 56 48 83 EC 20 80 B9", DetourName = nameof(RecipeNoteRecieveDetour))] + private readonly HookWrapper recipeNoteRecieveHook; + + private delegate void RecipeNoteIterateDelegate(SearchContext* a1); + [TweakHook, Signature("80 B9 ?? ?? ?? ?? ?? 74 27 8B 81", DetourName = nameof(RecipeNoteIterateDetour))] + private readonly HookWrapper recipeNoteIterateHook; + + private delegate void AgentItemSearchUpdate1Delegate(AgentItemSearch2* a1); + [TweakHook, Signature("E8 ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? 80 BB ?? ?? ?? ?? ?? 75 19", DetourName = nameof(AgentItemSearchUpdate1Detour))] + private readonly HookWrapper agentItemSearchUpdate1Hook; + + private delegate void AgentItemSearchUpdateAtkValuesDelegate(AgentItemSearch2* a1, uint a2, byte* a3, bool a4); + [TweakHook, Signature("40 55 56 41 56 B8", DetourName = nameof(AgentItemSearchUpdateAtkValuesDetour))] + private readonly HookWrapper agentItemSearchUpdateAtkValuesHook; + + private delegate void AgentItemSearchPushFoundItemsDelegate(AgentItemSearch2* a1); + [Signature("E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 2B F8")] + private readonly AgentItemSearchPushFoundItemsDelegate agentItemSearchPushFoundItems; + + private delegate void ResizeVectorDelegate(nint vector, int amt); + [Signature("E8 ?? ?? ?? ?? 48 8B 57 08 48 85 D2 74 2C")] + private readonly ResizeVectorDelegate resizeVector; + + [AddonPostSetup("ItemSearch")] + private void SetupItemSearch(AddonItemSearch* addon) { + var checkbox = addon->PartialSearchCheckBox; + var text = checkbox->AtkComponentButton.ButtonTextNode; + text->SetText(Config.UseFuzzySearch ? "Fuzzy Item Search" : "Fast Item Search"); + } + + private void RecipeNoteRecieveDetour(AgentRecipeNote2* a1, Utf8String* a2, bool a3, bool a4) { + if (!a1->RecipeSearchProcessing) { + recipeNoteRecieveHook.Original(a1, a2, a3, a4); + + RecipeSearch(a2->ToString(), &a1->SearchResults); + a1->SearchContext->IsComplete = true; + } + } + + private void RecipeNoteIterateDetour(SearchContext* a1) { + + } + + private void AgentItemSearchUpdate1Detour(AgentItemSearch2* a1) { + if (a1->IsPartialSearching && !a1->IsItemPushPending) { + ItemSearch(a1->StringData->SearchParam.ToString(), a1); + agentItemSearchPushFoundItems(a1); + } + } + + private void AgentItemSearchUpdateAtkValuesDetour(AgentItemSearch2* a1, uint a2, byte* a3, bool a4) { + var partialString = Service.Data.GetExcelSheet().GetRow(3136).Text.ToDalamudString().ToString(); + var isPartial = MemoryHelper.ReadStringNullTerminated((nint)a3).Equals(partialString, StringComparison.Ordinal); + if (isPartial) { + var newText = Encoding.UTF8.GetBytes(Config.UseFuzzySearch ? "Fuzzy Item Search" : "Fast Item Search"); + fixed (byte* t = newText) + { + a3 = t; + } + } + agentItemSearchUpdateAtkValuesHook.Original(a1, a2, a3, a4); + } + + private void RecipeSearch(string input, StdVector* output) { + if (string.IsNullOrWhiteSpace(input)) + return; + var sheet = Service.Data.GetExcelSheet().Where(r => r.RecipeLevelTable.Row != 0 && r.ItemResult.Row != 0); + var matcher = new FuzzyMatcher(input.ToLowerInvariant(), Config.UseFuzzySearch ? MatchMode.FuzzyParts : MatchMode.Simple); + var query = sheet.AsParallel() + .Select(i => (Item: i, Score: matcher.Matches(i.ItemResult.Value!.Name.ToDalamudString().ToString().ToLowerInvariant()))) + .Where(t => t.Score > 0) + .OrderByDescending(t => t.Score) + .Select(t => t.Item.RowId); + + foreach (var v in query) + PushBackVector(output, v); + } + + private void ItemSearch(string input, AgentItemSearch2* agent) { + if (string.IsNullOrWhiteSpace(input)) + return; + var sheet = Service.Data.GetExcelSheet().Where(i => i.ItemSearchCategory.Row != 0); + var matcher = new FuzzyMatcher(input.ToLowerInvariant(), Config.UseFuzzySearch ? MatchMode.FuzzyParts : MatchMode.Simple); + var query = sheet.AsParallel() + .Select(i => (Item: i, Score: matcher.Matches(i.Name.ToDalamudString().ToString().ToLowerInvariant()))) + .Where(t => t.Score > 0) + .OrderByDescending(t => t.Score) + .Select(t => t.Item.RowId); + foreach (var item in query) { + agent->ItemBuffer[agent->ItemCount++] = item; + if (agent->ItemCount >= 100) + break; + } + } + + private void PushBackVector(StdVector* self, T value) where T : unmanaged { + if (sizeof(T) != 4) + throw new ArgumentException("Only works with 4 byte types"); + + if (self->Size() == self->Capacity()) + resizeVector((nint)self, 1); + + *self->Last = value; + self->Last++; + } +} diff --git a/Utility/FuzzyMatcher.cs b/Utility/FuzzyMatcher.cs new file mode 100644 index 00000000..0b8d4d27 --- /dev/null +++ b/Utility/FuzzyMatcher.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace SimpleTweaksPlugin.Utility; + +public readonly struct FuzzyMatcher { + private const bool IsBorderMatching = true; + private static readonly (int, int)[] EmptySegArray = Array.Empty<(int, int)>(); + + private readonly string needleString = string.Empty; + private readonly int needleFinalPosition = -1; + private readonly (int Start, int End)[] needleSegments = EmptySegArray; + private readonly MatchMode mode = MatchMode.Simple; + + public FuzzyMatcher(string term, MatchMode matchMode) { + needleString = term; + needleFinalPosition = needleString.Length - 1; + mode = matchMode; + + needleSegments = matchMode switch { + MatchMode.FuzzyParts => FindNeedleSegments(needleString), + MatchMode.Fuzzy or MatchMode.Simple => EmptySegArray, + _ => throw new ArgumentOutOfRangeException(nameof(matchMode), matchMode, "Invalid match mode"), + }; + } + + private static (int Start, int End)[] FindNeedleSegments(ReadOnlySpan span) { + var segments = new List<(int, int)>(); + var wordStart = -1; + + for (var i = 0; i < span.Length; i++) { + if (span[i] is not ' ' and not '\u3000') { + if (wordStart < 0) + wordStart = i; + } + else if (wordStart >= 0) { + segments.Add((wordStart, i - 1)); + wordStart = -1; + } + } + + if (wordStart >= 0) + segments.Add((wordStart, span.Length - 1)); + + return segments.ToArray(); + } + + public int Matches(string value) { + if (needleFinalPosition < 0) + return 0; + + if (mode == MatchMode.Simple) + return value.Contains(needleString, StringComparison.InvariantCultureIgnoreCase) ? 1 : 0; + + if (mode == MatchMode.Fuzzy) + return GetRawScore(value, 0, needleFinalPosition); + + if (mode == MatchMode.FuzzyParts) { + if (needleSegments.Length < 2) + return GetRawScore(value, 0, needleFinalPosition); + + var total = 0; + for (var i = 0; i < needleSegments.Length; i++) { + var (start, end) = needleSegments[i]; + var cur = GetRawScore(value, start, end); + if (cur == 0) + return 0; + + total += cur; + } + + return total; + } + + return 8; + } + + public int MatchesAny(params string[] values) { + var max = 0; + for (var i = 0; i < values.Length; i++) { + var cur = Matches(values[i]); + if (cur > max) + max = cur; + } + + return max; + } + + private int GetRawScore(ReadOnlySpan haystack, int needleStart, int needleEnd) { + var (startPos, gaps, consecutive, borderMatches, endPos) = FindForward(haystack, needleStart, needleEnd); + if (startPos < 0) + return 0; + + var needleSize = needleEnd - needleStart + 1; + + var score = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + + (startPos, gaps, consecutive, borderMatches) = FindReverse(haystack, endPos, needleStart, needleEnd); + var revScore = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + + return int.Max(score, revScore); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive, int borderMatches) { + var score = 100 + + needleSize * 3 + + borderMatches * 3 + + consecutive * 5 + - startPos + - gaps * 2; + if (startPos == 0) + score += 5; + return score < 1 ? 1 : score; + } + + private (int StartPos, int Gaps, int Consecutive, int BorderMatches, int HaystackIndex) FindForward( + ReadOnlySpan haystack, int needleStart, int needleEnd) { + var needleIndex = needleStart; + var lastMatchIndex = -10; + + var startPos = 0; + var gaps = 0; + var consecutive = 0; + var borderMatches = 0; + + for (var haystackIndex = 0; haystackIndex < haystack.Length; haystackIndex++) { + if (haystack[haystackIndex] == needleString[needleIndex]) { + if (IsBorderMatching) { + if (haystackIndex > 0) { + if (!char.IsLetterOrDigit(haystack[haystackIndex - 1])) + borderMatches++; + } + } + + needleIndex++; + + if (haystackIndex == lastMatchIndex + 1) + consecutive++; + + if (needleIndex > needleEnd) + return (startPos, gaps, consecutive, borderMatches, haystackIndex); + + lastMatchIndex = haystackIndex; + } + else { + if (needleIndex > needleStart) + gaps++; + else + startPos++; + } + } + + return (-1, 0, 0, 0, 0); + } + + private (int StartPos, int Gaps, int Consecutive, int BorderMatches) FindReverse( + ReadOnlySpan haystack, int haystackLastMatchIndex, int needleStart, int needleEnd) { + var needleIndex = needleEnd; + var revLastMatchIndex = haystack.Length + 10; + + var gaps = 0; + var consecutive = 0; + var borderMatches = 0; + + for (var haystackIndex = haystackLastMatchIndex; haystackIndex >= 0; haystackIndex--) { + if (haystack[haystackIndex] == needleString[needleIndex]) { + if (IsBorderMatching) { + if (haystackIndex > 0) { + if (!char.IsLetterOrDigit(haystack[haystackIndex - 1])) + borderMatches++; + } + } + + needleIndex--; + + if (haystackIndex == revLastMatchIndex - 1) + consecutive++; + + if (needleIndex < needleStart) + return (haystackIndex, gaps, consecutive, borderMatches); + + revLastMatchIndex = haystackIndex; + } + else + gaps++; + } + + return (-1, 0, 0, 0); + } +} + +public enum MatchMode { + Simple, + Fuzzy, + FuzzyParts, +}