From e43c71facaef0bb987bc5f785c88191c5b466659 Mon Sep 17 00:00:00 2001 From: ansidian Date: Tue, 16 Jun 2026 11:00:53 -0700 Subject: [PATCH 1/5] feat(stashie): dynamic item ignore with persistent lock icon Lock an inventory item (configurable hotkey, default middle-click) to skip it when stashing and mark it with a lock icon in any cell it occupies. - DynamicIgnore: hotkey toggle, per-cell icon draw, stash-skip - ItemFingerprint: stable per-item identity for locks - locks are persisted across restarts and self-pruned against the live inventory at stash time so stale entries don't accumulate - bundles images/lock.png and copies it to the output directory; falls back to a primitive marker if the texture is missing --- Classes/ItemFingerprint.cs | 58 +++++++++ Compartments/DynamicIgnore.cs | 224 ++++++++++++++++++++++++++++++++++ Compartments/FilterManager.cs | 8 ++ Stashie.cs | 26 ++++ Stashie.csproj | 5 + StashieSettings.cs | 22 ++++ images/lock.png | Bin 0 -> 19335 bytes 7 files changed, 343 insertions(+) create mode 100644 Classes/ItemFingerprint.cs create mode 100644 Compartments/DynamicIgnore.cs create mode 100644 images/lock.png diff --git a/Classes/ItemFingerprint.cs b/Classes/ItemFingerprint.cs new file mode 100644 index 0000000..b31691a --- /dev/null +++ b/Classes/ItemFingerprint.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExileCore2.PoEMemory.Components; +using ExileCore2.PoEMemory.MemoryObjects; + +namespace Stashie.Classes; + +/// +/// Builds a stable identity string ("fingerprint") for an inventory item, used by the +/// dynamic-ignore feature to recognise the same item regardless of which cell it occupies. +/// Pure and stateless: same item state always yields the same string. +/// +public static class ItemFingerprint +{ + /// + /// Returns a stable fingerprint for the given item entity, or null if the entity is null. + /// Falls back to the entity path when the item has no Mods component. + /// The fingerprint combines base path + rarity + unique name + every implicit/explicit/ + /// corruption/enchant mod's RawName and roll Values (exact-item matching). + /// + public static string Build(Entity itemEntity) + { + if (itemEntity == null) + return null; + + var mods = itemEntity.GetComponent(); + if (mods == null) + return itemEntity.Path; + + var tokens = new List + { + $"B:{itemEntity.Path}", + $"R:{mods.ItemRarity}", + $"U:{mods.UniqueName}" + }; + + AppendMods(tokens, "I", mods.ImplicitMods); + AppendMods(tokens, "E", mods.ExplicitMods); + AppendMods(tokens, "C", mods.CorruptionImplicitMods); + AppendMods(tokens, "N", mods.EnchantedMods); + + return string.Join("|", tokens); + } + + private static void AppendMods(List tokens, string prefix, List mods) + { + if (mods == null || mods.Count == 0) + return; + + // Sort within each category so the framework's memory ordering can't change the result. + var modTokens = mods + .Select(m => $"{prefix}:{m.RawName}:{string.Join(",", m.Values ?? new List())}") + .OrderBy(s => s, StringComparer.Ordinal); + + tokens.AddRange(modTokens); + } +} diff --git a/Compartments/DynamicIgnore.cs b/Compartments/DynamicIgnore.cs new file mode 100644 index 0000000..5414a1f --- /dev/null +++ b/Compartments/DynamicIgnore.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using ExileCore2; +using ExileCore2.PoEMemory.MemoryObjects; +using ExileCore2.Shared; +using Stashie.Classes; +using static Stashie.StashieCore; +using Color = System.Drawing.Color; +using Vector2 = System.Numerics.Vector2; + +namespace Stashie.Compartments; + +/// +/// Dynamic, mod-based item ignore. The user middle-clicks an inventory item to lock it; +/// locked items are skipped when stashing and marked with a lock icon. The locked set is +/// persisted on StashieSettings (serialized to the plugin's settings JSON) and self-pruned: +/// entries whose item is no longer in the inventory are dropped at the start of each stash run. +/// +internal static class DynamicIgnore +{ + /// Registered texture name for the lock icon (see Stashie.Initialise). + public const string LockImageName = "stashie_lock"; + + // Edge-detection state for the lock hotkey (so a held button toggles only once). + private static bool _hotkeyWasDown; + + /// Clears all locks (e.g. if a manual reset is ever wired up). + public static void Clear() => Main.Settings.LockedItemFingerprints.Clear(); + + /// True if the item's fingerprint is currently locked. + public static bool IsLocked(Entity itemEntity) + { + var locked = Main.Settings.LockedItemFingerprints; + if (locked == null || locked.Count == 0) + return false; + + var fingerprint = ItemFingerprint.Build(itemEntity); + return fingerprint != null && locked.Contains(fingerprint); + } + + /// Locks the item if unlocked, unlocks it if already locked. + public static void Toggle(Entity itemEntity) + { + var fingerprint = ItemFingerprint.Build(itemEntity); + if (fingerprint == null) + return; + + var locked = Main.Settings.LockedItemFingerprints; + if (locked.Contains(fingerprint)) + locked.RemoveAll(fp => fp == fingerprint); // unlock (dup-safe) + else + locked.Add(fingerprint); // lock + } + + /// + /// Drops any locked fingerprint whose item is not present in the given inventory. + /// Called at the start of a stash run, when the inventory read is stable and complete. + /// Skips pruning on an empty present-set (transient/garbage read) to avoid wiping all locks. + /// + public static void PruneToInventory(IEnumerable invItems) + { + var locked = Main.Settings.LockedItemFingerprints; + if (locked == null || locked.Count == 0 || invItems == null) + return; + + var present = new HashSet(); + foreach (var invItem in invItems) + { + if (invItem?.Item == null || invItem.Address == 0) + continue; + + var fingerprint = ItemFingerprint.Build(invItem.Item); + if (fingerprint != null) + present.Add(fingerprint); + } + + if (present.Count == 0) + return; // nothing legitimately present to reconcile against; keep locks. + + locked.RemoveAll(fp => !present.Contains(fp)); + } + + /// Called every Tick. Toggles the hovered item on a hotkey press edge. + public static void HandleHotkey() + { + if (!Main.Settings.EnableDynamicIgnore) + return; + + if (!IsInventoryOpen()) + { + _hotkeyWasDown = false; + return; + } + + var key = Main.Settings.DynamicIgnoreHotkey.Value; + var isDown = Input.IsKeyDown(key); + var pressedThisFrame = isDown && !_hotkeyWasDown; + _hotkeyWasDown = isDown; + + if (!pressedThisFrame) + return; + + var hovered = GetHoveredInventoryItem(); + if (hovered?.Item != null) + Toggle(hovered.Item); + } + + /// Called every Render. Draws a lock icon on each locked inventory item. + public static void DrawIcons() + { + if (!Main.Settings.EnableDynamicIgnore) + return; + + var locked = Main.Settings.LockedItemFingerprints; + if (locked == null || locked.Count == 0) + return; + + if (!IsInventoryOpen()) + return; + + var invItems = GetInventorySlotItems(); + if (invItems == null) + return; + + var tint = Main.Settings.DynamicIgnoreIconTint.Value; + var sizePct = Main.Settings.DynamicIgnoreIconSizePercent.Value / 100f; + var corner = Main.Settings.DynamicIgnoreIconCorner.Value; + var hasImage = Main.Graphics.HasImage(LockImageName); + + foreach (var invItem in invItems) + { + if (invItem?.Item == null || invItem.Address == 0) + continue; + + if (!IsLocked(invItem.Item)) + continue; + + var rect = ComputeIconRect(invItem.GetClientRect(), sizePct, corner); + if (hasImage) + Main.Graphics.DrawImage(LockImageName, rect, tint); + else + DrawFallbackLock(rect, tint); + } + } + + /// A square icon rect of of the cell's shorter side, in a corner. + private static RectangleF ComputeIconRect(RectangleF cell, float pct, string corner) + { + var side = Math.Min(cell.Width, cell.Height) * pct; + const float inset = 1f; + float x, y; + switch (corner) + { + case "Top-Left": + x = cell.Left + inset; y = cell.Top + inset; break; + case "Bottom-Right": + x = cell.Right - side - inset; y = cell.Bottom - side - inset; break; + case "Bottom-Left": + x = cell.Left + inset; y = cell.Bottom - side - inset; break; + default: // "Top-Right" + x = cell.Right - side - inset; y = cell.Top + inset; break; + } + + return new RectangleF(x, y, side, side); + } + + /// Primitive padlock (filled body + shackle arc) drawn when the PNG failed to load. + private static void DrawFallbackLock(RectangleF r, Color color) + { + var bodyTop = r.Top + r.Height * 0.45f; + var body = new RectangleF(r.Left, bodyTop, r.Width, r.Bottom - bodyTop); + Main.Graphics.DrawBox(body, color); + + var shackleRadius = r.Width * 0.28f; + var shackleCenter = new Vector2(r.Left + r.Width / 2f, bodyTop); + Main.Graphics.DrawCircle(shackleCenter, shackleRadius, color, 2f); + } + + private static bool IsInventoryOpen() + { + var panel = Main.GameController?.Game?.IngameState?.IngameUi?.InventoryPanel; + return panel != null && panel.IsVisible; + } + + internal static IList GetInventorySlotItems() + { + try + { + return Main.GameController.Game.IngameState.Data.ServerData + .PlayerInventories[0].Inventory.InventorySlotItems; + } + catch + { + // ServerData can be transiently null/incomplete during zone/UI transitions. + return null; + } + } + + internal static ServerInventory.InventSlotItem GetHoveredInventoryItem() + { + var invItems = GetInventorySlotItems(); + if (invItems == null) + return null; + + // GetClientRect() is window-relative; the mouse position is screen-space. + var windowOffset = Main.GameController.Window.GetWindowRectangle().TopLeft; + var mouse = Input.MousePosition; + + foreach (var invItem in invItems) + { + if (invItem?.Item == null || invItem.Address == 0) + continue; + + var rect = invItem.GetClientRect(); + rect.Offset(windowOffset.X, windowOffset.Y); + + if (rect.Contains(mouse)) + return invItem; + } + + return null; + } +} diff --git a/Compartments/FilterManager.cs b/Compartments/FilterManager.cs index c61fbd1..e704d15 100644 --- a/Compartments/FilterManager.cs +++ b/Compartments/FilterManager.cs @@ -87,6 +87,11 @@ public static async SyncTask ParseItems() var invItems = _serverData.PlayerInventories[0].Inventory.InventorySlotItems; await TaskUtils.CheckEveryFrameWithThrow(() => invItems != null, new CancellationTokenSource(500).Token); + + // Drop locks whose item is no longer in the inventory (keeps the persisted set from + // accumulating stale entries). Safe here: stable, complete inventory read; no item dragging. + DynamicIgnore.PruneToInventory(invItems); + Main.DropItems = []; Main.ClickWindowOffset = Main.GameController.Window.GetWindowRectangle().TopLeft; @@ -98,6 +103,9 @@ public static async SyncTask ParseItems() if (Utility.CheckIgnoreCells(invItem, (12, 5), Main.Settings.IgnoredCells)) continue; + if (DynamicIgnore.IsLocked(invItem.Item)) + continue; + var testItem = new ItemData(invItem.Item, Main.GameController); var result = CheckFilters(testItem, invItem.GetClientRect().Center); if (result != null) diff --git a/Stashie.cs b/Stashie.cs index 829bea8..d590b83 100644 --- a/Stashie.cs +++ b/Stashie.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Reflection; using ExileCore2; using ImGuiNET; using Stashie.Classes; @@ -53,10 +55,30 @@ public override bool Initialise() Utility.SetupOrClose(); Input.RegisterKey(Settings.DropHotkey); + Input.RegisterKey(Settings.DynamicIgnoreHotkey.Value); Settings.DropHotkey.OnValueChanged += () => { Input.RegisterKey(Settings.DropHotkey); }; + Settings.DynamicIgnoreHotkey.OnValueChanged += () => { Input.RegisterKey(Settings.DynamicIgnoreHotkey.Value); }; Settings.FilterFile.OnValueSelected = _ => FilterManager.LoadCustomFilters(); + // Dynamic-ignore lock icon: register the texture once. If it can't be found, DrawIcons + // falls back to a primitive marker, so this is non-fatal. + if (!Graphics.InitImage(DynamicIgnore.LockImageName, Path.Combine(DirectoryFullName, "images", "lock.png"))) + { + var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (asmDir == null || + !Graphics.InitImage(DynamicIgnore.LockImageName, Path.Combine(asmDir, "images", "lock.png"))) + { + LogError("Stashie: lock.png not found; dynamic-ignore will use a fallback marker."); + } + } + + // ListNode options are not serialized — repopulate them every load. + Settings.DynamicIgnoreIconCorner.SetListValues( + new List { "Top-Right", "Top-Left", "Bottom-Right", "Bottom-Left" }); + if (string.IsNullOrEmpty(Settings.DynamicIgnoreIconCorner.Value)) + Settings.DynamicIgnoreIconCorner.Value = "Top-Right"; + return true; } @@ -64,6 +86,8 @@ public override void Render() { try { + DynamicIgnore.DrawIcons(); + if (Settings.InspectInventoryItems) GameController.InspectObject(FilterManager.GetInventoryItems(), "Stashie item data"); } @@ -126,6 +150,8 @@ public override void AreaChange(AreaInstance area) public override void Tick() { + DynamicIgnore.HandleHotkey(); + if (!StashingRequirementsMet()) { TaskRunner.Stop("Stashie_DropItemsToStash"); diff --git a/Stashie.csproj b/Stashie.csproj index 18f4972..85bcad8 100644 --- a/Stashie.csproj +++ b/Stashie.csproj @@ -31,4 +31,9 @@ False + + + PreserveNewest + + \ No newline at end of file diff --git a/StashieSettings.cs b/StashieSettings.cs index 410207d..01b97e9 100644 --- a/StashieSettings.cs +++ b/StashieSettings.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Drawing; using System.Text.Json.Serialization; using System.Windows.Forms; using ExileCore2.Shared.Attributes; @@ -46,6 +47,27 @@ public class StashieSettings : ISettings [Menu("Keep Highest ID Scroll Stack")] public ToggleNode KeepHighestIDStack { get; set; } = new(false); + [Menu("Enable Dynamic Ignore", + "Middle-click an inventory item to lock it. Locked items are skipped when stashing and marked with a lock icon, no matter which cell they are in. Locks persist across restarts and are dropped automatically once the item is no longer in your inventory.")] + public ToggleNode EnableDynamicIgnore { get; set; } = new(true); + + [Menu("Dynamic Ignore: Lock/Unlock Hotkey", + "Hover an inventory item and press this to lock or unlock it. Default: middle mouse button.")] + public HotkeyNode DynamicIgnoreHotkey { get; set; } = Keys.MButton; + + [Menu("Dynamic Ignore: Icon Tint", "Tints the lock icon. White shows the icon's original colors.")] + public ColorNode DynamicIgnoreIconTint { get; set; } = new(Color.White); + + [Menu("Dynamic Ignore: Icon Size (% of cell)", "Lock icon edge length as a percentage of the item cell.")] + public RangeNode DynamicIgnoreIconSizePercent { get; set; } = new(30, 20, 100); + + [Menu("Dynamic Ignore: Icon Corner", "Which corner of the item cell the lock icon sits in.")] + public ListNode DynamicIgnoreIconCorner { get; set; } = new() { Value = "Top-Right" }; + + // Persisted set of locked item fingerprints. No [Menu] -> hidden from UI but serialized to + // the plugin's settings JSON (like IgnoredCells). Self-pruned at stash time. See DynamicIgnore. + public List LockedItemFingerprints { get; set; } = new(); + public int[,] IgnoredCells { get; set; } = new int[5, 12]; public int[,] IgnoredExpandedCells { get; set; } = new int[5, 4]; diff --git a/images/lock.png b/images/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..e9879ef0eaef6dbf220f47241ed8a19ebf12b838 GIT binary patch literal 19335 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 literal 0 HcmV?d00001 From cb2e4170affca5aa28861bc63d9cf3bf6dc0b6ff Mon Sep 17 00:00:00 2001 From: ansidian Date: Tue, 16 Jun 2026 11:01:26 -0700 Subject: [PATCH 2/5] feat(filter): GUI filter-builder editor with item scanner Replaces hand-editing filter JSON with a visual editor that round-trips losslessly to and from the raw query. - condition model + attribute catalog + query compiler/parser for a non-destructive raw<->tree round-trip (raw fallback preserves anything the builder can't model) - recursive AND/OR tree editor with per-node menus and arbitrary nesting, built on a pure ConditionTreeOps (wrap/convert/move/ungroup/delete) - item scanner: grab hovered items (inventory/stash/ground) into a discovery pool and turn their mods into conditions - debounced auto-save with live reload, per-column scrolling, defensive item reads - stash-tab assignment keyed by stable filter Id, migrated from the name-keyed store - scoped StashieTheme + Controls helpers --- Classes/AttributeCatalog.cs | 81 +++++ Classes/ConditionModel.cs | 32 ++ Classes/ConditionTreeOps.cs | 119 ++++++ Classes/FilterEditor.cs | 13 + Compartments/ConditionTreeEditor.cs | 265 ++++++++++++++ Compartments/FilterBuilderEditor.cs | 480 +++++++++++++++++++++++++ Compartments/FilterManager.cs | 34 +- Compartments/ItemScanner.cs | 251 +++++++++++++ Compartments/QueryCompiler.cs | 91 +++++ Compartments/QueryParser.cs | 225 ++++++++++++ Compartments/StashieEditorHandler.cs | 226 ++++-------- Compartments/StashieSettingsHandler.cs | 13 +- Compartments/TabAssignmentMigration.cs | 30 ++ Stashie.cs | 3 + StashieSettings.cs | 7 + Ui/Controls.cs | 42 +++ Ui/StashieTheme.cs | 57 +++ 17 files changed, 1803 insertions(+), 166 deletions(-) create mode 100644 Classes/AttributeCatalog.cs create mode 100644 Classes/ConditionModel.cs create mode 100644 Classes/ConditionTreeOps.cs create mode 100644 Compartments/ConditionTreeEditor.cs create mode 100644 Compartments/FilterBuilderEditor.cs create mode 100644 Compartments/ItemScanner.cs create mode 100644 Compartments/QueryCompiler.cs create mode 100644 Compartments/QueryParser.cs create mode 100644 Compartments/TabAssignmentMigration.cs create mode 100644 Ui/Controls.cs create mode 100644 Ui/StashieTheme.cs diff --git a/Classes/AttributeCatalog.cs b/Classes/AttributeCatalog.cs new file mode 100644 index 0000000..146c12c --- /dev/null +++ b/Classes/AttributeCatalog.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +namespace Stashie.Classes; + +public enum AttrKind { String, Number, Bool, Enum } + +public class AttrDef +{ + public string Key; // user-facing + ConditionNode.Attribute + public string Dsl; // DSL property, e.g. "ClassName" + public AttrKind Kind; + public string EnumType; // for Enum kind, e.g. "ItemRarity" + public string[] EnumValues; // for Enum kind, e.g. ["Normal","Magic","Rare","Unique"] + public string[] Operators; // operators offered in the UI +} + +public static class AttributeCatalog +{ + // Operators are also what QueryCompiler understands. Map flags use has/notHas. + public static readonly List All = + [ + new() { Key = "ClassName", Dsl = "ClassName", Kind = AttrKind.String, Operators = ["==","!=","contains"] }, + new() { Key = "BaseName", Dsl = "BaseName", Kind = AttrKind.String, Operators = ["==","!=","contains"] }, + new() { Key = "Path", Dsl = "Path", Kind = AttrKind.String, Operators = ["contains"] }, + new() { Key = "Rarity", Dsl = "Rarity", Kind = AttrKind.Enum, EnumType = "ItemRarity", + EnumValues = ["Normal","Magic","Rare","Unique"], Operators = ["==","!="] }, + new() { Key = "ItemLevel", Dsl = "ItemLevel", Kind = AttrKind.Number, Operators = ["==","!=",">=","<=",">","<"] }, + new() { Key = "ItemQuality",Dsl = "ItemQuality",Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + new() { Key = "MapTier", Dsl = "MapTier", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + new() { Key = "Sockets", Dsl = "SocketInfo.SocketNumber", Kind = AttrKind.Number, Operators = ["==",">="] }, + new() { Key = "Links", Dsl = "SocketInfo.LargestLinkSize", Kind = AttrKind.Number, Operators = ["==",">="] }, + new() { Key = "StackSize", Dsl = "StackInfo.Count", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + + new() { Key = "IsCorrupted", Dsl = "IsCorrupted", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + new() { Key = "IsIdentified", Dsl = "IsIdentified", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + new() { Key = "IsMap", Dsl = "IsMap", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + new() { Key = "IsWeapon", Dsl = "IsWeapon", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + new() { Key = "Enchanted", Dsl = "Enchanted", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + new() { Key = "IsBlightMap", Dsl = "IsBlightMap", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + new() { Key = "IsElderGuardianMap", Dsl = "IsElderGuardianMap", Kind = AttrKind.Bool, Operators = ["has","notHas"] }, + ]; + + public static AttrDef Find(string key) + { + foreach (var d in All) + if (d.Key == key) return d; + return null; + } + + public static AttrDef FindByDsl(string dsl) + { + foreach (var d in All) + if (d.Dsl == dsl) return d; + return null; + } + + // Drops entries whose DSL doesn't compile against the live ItemFilterLibrary build, + // so guessed accessors (MapTier, IsMap, ...) never ship broken. Call once at load. + public static void ProbeAndPrune(System.Action logDropped = null) + { + var kept = new List(); + foreach (var d in All) + { + var probe = d.Kind switch + { + AttrKind.Number => $"{d.Dsl} >= 0", + AttrKind.Bool => d.Dsl, + AttrKind.Enum => $"{d.Dsl} == {d.EnumType}.{d.EnumValues[0]}", + _ => $"{d.Dsl} == \"x\"", + }; + + if (Compartments.QueryCompiler.Validate(probe).ok) + kept.Add(d); + else + logDropped?.Invoke(d.Key); + } + + All.Clear(); + All.AddRange(kept); + } +} diff --git a/Classes/ConditionModel.cs b/Classes/ConditionModel.cs new file mode 100644 index 0000000..d0d4e51 --- /dev/null +++ b/Classes/ConditionModel.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace Stashie.Classes; + +// Tagged-union node so Newtonsoft (de)serializes without a polymorphic converter. +// Kind == "And"/"Or" -> group, uses Children. +// Kind == "Leaf" -> single condition: a Mod (if Mod != null) or an Attribute. +public class ConditionNode +{ + public string Kind = "And"; // "And" | "Or" | "Leaf" + public List Children = []; // groups only + + // Leaf (attribute) fields: + public string Attribute; // AttributeCatalog key, e.g. "ClassName" + public string Operator; // "==","!=",">=","<=",">","<","contains","has","notHas" + public string Value; // raw operand, compiler types/escapes it + public string Raw; // when non-null on a Leaf: verbatim DSL fragment (round-trip fallback) + + public ModRef Mod; // leaf is a mod condition when non-null +} + +public class ModRef +{ + public string Display; // human chip text, e.g. "+ to Spirit" + public string MatchToken; // FindMods(...) token (group or validated stem) + public string[] GameStats = []; // resolved GameStat names for stat-mode + public bool MatchByStat; // per-condition toggle + public string Mode = "Value";// "Has" | "Value" + public int ValueIndex; // which Values[] index + public int Threshold; // value for "Value" mode + public int? Min, Max; // affix range for the slider +} diff --git a/Classes/ConditionTreeOps.cs b/Classes/ConditionTreeOps.cs new file mode 100644 index 0000000..59c455d --- /dev/null +++ b/Classes/ConditionTreeOps.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; + +namespace Stashie.Classes; + +// Pure structural mutations on a ConditionNode tree. No ImGui, no I/O — unit-testable. +// All identity is reference identity (ConditionNode does not override Equals). +public static class ConditionTreeOps +{ + public static bool IsGroup(ConditionNode n) => n != null && (n.Kind == "And" || n.Kind == "Or"); + + public static Dictionary BuildParentMap(ConditionNode root) + { + var map = new Dictionary(); + void Walk(ConditionNode n) + { + if (n?.Children == null) return; + foreach (var c in n.Children) { map[c] = n; Walk(c); } + } + Walk(root); + return map; + } + + // All groups in pre-order (root first). + public static List Groups(ConditionNode root) + { + var list = new List(); + void Walk(ConditionNode n) + { + if (!IsGroup(n)) return; + list.Add(n); + if (n.Children == null) return; + foreach (var c in n.Children) Walk(c); + } + Walk(root); + return list; + } + + // True if 'ancestor' is 'node' or contains it anywhere in its subtree. + public static bool Contains(ConditionNode ancestor, ConditionNode node) + { + if (ancestor == null) return false; + if (ReferenceEquals(ancestor, node)) return true; + if (ancestor.Children == null) return false; + foreach (var c in ancestor.Children) + if (Contains(c, node)) return true; + return false; + } + + // Replace 'node' in parent.Children with a new group of 'kind' that contains 'node'. + public static ConditionNode Wrap(ConditionNode parent, ConditionNode node, string kind) + { + if (parent?.Children == null) return null; + var idx = parent.Children.IndexOf(node); + if (idx < 0) return null; + var group = new ConditionNode { Kind = kind, Children = [node] }; + parent.Children[idx] = group; + return group; + } + + public static void Convert(ConditionNode group) + { + if (!IsGroup(group)) return; + if (group.Kind == "And") group.Kind = "Or"; + else group.Kind = "And"; + } + + // Reparent 'node' to the end of 'dest'. Rejected if dest is not a group, or moving + // would create a cycle (dest == node or a descendant of node), or dest is the current parent. + public static bool Move(ConditionNode root, ConditionNode node, ConditionNode dest) + { + if (!IsGroup(dest)) return false; + if (Contains(node, dest)) return false; + var map = BuildParentMap(root); + if (!map.TryGetValue(node, out var parent)) return false; + if (ReferenceEquals(parent, dest)) return false; + parent.Children.Remove(node); + dest.Children.Add(node); + return true; + } + + // Safe only when parent has the same connective, or the group has <=1 child — + // otherwise inlining would silently change boolean meaning. + public static bool CanUngroup(ConditionNode root, ConditionNode group) + { + if (!IsGroup(group)) return false; + var map = BuildParentMap(root); + if (!map.TryGetValue(group, out var parent)) return false; // root can't ungroup + return parent.Kind == group.Kind || group.Children.Count <= 1; + } + + public static bool Ungroup(ConditionNode root, ConditionNode group) + { + if (!CanUngroup(root, group)) return false; + var parent = BuildParentMap(root)[group]; + var idx = parent.Children.IndexOf(group); + parent.Children.RemoveAt(idx); + parent.Children.InsertRange(idx, group.Children); + return true; + } + + public static bool Delete(ConditionNode root, ConditionNode node) + { + var map = BuildParentMap(root); + if (!map.TryGetValue(node, out var parent)) return false; + return parent.Children.Remove(node); + } + + // Replace 'oldNode' with 'newNode' in its parent. Returns true if replaced. + public static bool Replace(ConditionNode root, ConditionNode oldNode, ConditionNode newNode) + { + if (newNode == null) return false; + var map = BuildParentMap(root); + if (!map.TryGetValue(oldNode, out var parent)) return false; + var idx = parent.Children.IndexOf(oldNode); + if (idx < 0) return false; + parent.Children[idx] = newNode; + return true; + } +} diff --git a/Classes/FilterEditor.cs b/Classes/FilterEditor.cs index 4e17637..f42f8f3 100644 --- a/Classes/FilterEditor.cs +++ b/Classes/FilterEditor.cs @@ -21,6 +21,19 @@ public class Filter public string FilterName; public string RawQuery; public bool Shifting = false; + + public string Id; // NEW: stable GUID; assigned on create/load + public ConditionNode Conditions; // NEW: optional structured tree (null => raw-only) + + // Runtime-only compile state for the builder; ignored by both serializers + // (Newtonsoft for filter files, System.Text.Json for settings persistence). + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public string _lastCompiled; + + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public bool? _lastCompileOk; } } diff --git a/Compartments/ConditionTreeEditor.cs b/Compartments/ConditionTreeEditor.cs new file mode 100644 index 0000000..9075b94 --- /dev/null +++ b/Compartments/ConditionTreeEditor.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ImGuiNET; +using Stashie.Classes; +using Stashie.Ui; +using Vector2N = System.Numerics.Vector2; + +namespace Stashie.Compartments; + +// Recursive ImGui editor over a ConditionNode tree. Structural mutations are deferred: +// each frame records at most one PendingOp and applies it after the render pass. +public static class ConditionTreeEditor +{ + public static ConditionNode ActiveGroup; // focused target for scanner '+' + + private enum OpKind { None, Delete, WrapAnd, WrapOr, Convert, Ungroup, Move, Replace } + private static OpKind _op; + private static ConditionNode _opNode; + private static ConditionNode _opDest; + + public static ConditionNode NewAttributeLeaf() => + new() { Kind = "Leaf", Attribute = AttributeCatalog.All[0].Key, + Operator = AttributeCatalog.All[0].Operators[0], Value = "" }; + + public static void ValidateActive(ConditionNode root) + { + if (root == null) { ActiveGroup = null; return; } + if (ActiveGroup == null || !ConditionTreeOps.IsGroup(ActiveGroup) || + !ConditionTreeOps.Contains(root, ActiveGroup)) + ActiveGroup = root; + } + + // Returns true if the tree changed (caller recompiles). + public static bool Draw(ConditionNode root) + { + ValidateActive(root); + _op = OpKind.None; _opNode = null; _opDest = null; + + var changed = DrawNode(root, root); + + if (_op != OpKind.None && _opNode != null) + changed |= ApplyPending(root); + + ValidateActive(root); + return changed; + } + + private static ConditionNode Parent(ConditionNode root, ConditionNode n) => + ConditionTreeOps.BuildParentMap(root).GetValueOrDefault(n); + + private static bool ApplyPending(ConditionNode root) + { + switch (_op) + { + case OpKind.Delete: return ConditionTreeOps.Delete(root, _opNode); + case OpKind.Convert: ConditionTreeOps.Convert(_opNode); return true; + case OpKind.Ungroup: return ConditionTreeOps.Ungroup(root, _opNode); + case OpKind.Move: return ConditionTreeOps.Move(root, _opNode, _opDest); + case OpKind.WrapAnd: + case OpKind.WrapOr: + { + var p = Parent(root, _opNode); + if (p == null) return false; + return ConditionTreeOps.Wrap(p, _opNode, _op == OpKind.WrapAnd ? "And" : "Or") != null; + } + case OpKind.Replace: + return ConditionTreeOps.Replace(root, _opNode, _opDest); + default: return false; + } + } + + private static bool DrawNode(ConditionNode node, ConditionNode root) => + ConditionTreeOps.IsGroup(node) ? DrawGroup(node, root) : DrawLeaf(node, root); + + private static bool DrawGroup(ConditionNode group, ConditionNode root) + { + var changed = false; + var isActive = ReferenceEquals(group, ActiveGroup); + var isOr = group.Kind == "Or"; + + if (isActive) ImGui.PushStyleColor(ImGuiCol.Border, Controls.Accent); + ImGui.BeginChild("grp", new Vector2N(0, 0), + ImGuiChildFlags.Border | ImGuiChildFlags.AutoResizeY); + + Controls.StatusDot(true); + ImGui.SameLine(); + ImGui.TextColored(isOr ? Controls.AttrColor(AttrKind.Enum) : Controls.Accent, + isOr ? "Any of" : "All of"); + ImGui.SameLine(); + ImGui.TextDisabled(isOr ? "(OR)" : "(AND)"); + + ImGui.SameLine(); + if (ImGui.SmallButton(isActive ? "* target" : "o target")) ActiveGroup = group; + + ImGui.SameLine(); + if (ImGui.SmallButton("menu")) ImGui.OpenPopup("grpmenu"); + if (ImGui.BeginPopup("grpmenu")) + { + if (ImGui.MenuItem(isOr ? "Convert to AND" : "Convert to OR")) { _op = OpKind.Convert; _opNode = group; } + DrawMoveMenu(group, root); + var canUngroup = ConditionTreeOps.CanUngroup(root, group); + if (ImGui.MenuItem("Ungroup", "", false, canUngroup)) { _op = OpKind.Ungroup; _opNode = group; } + if (Parent(root, group) != null && ImGui.MenuItem("Delete group")) { _op = OpKind.Delete; _opNode = group; } + ImGui.EndPopup(); + } + + ImGui.Indent(); + for (var i = 0; i < group.Children.Count; i++) + { + ImGui.PushID(i); + if (DrawNode(group.Children[i], root)) changed = true; + ImGui.PopID(); + } + + if (ImGui.SmallButton("+ condition")) { group.Children.Add(NewAttributeLeaf()); ActiveGroup = group; changed = true; } + ImGui.SameLine(); + if (ImGui.SmallButton("+ group")) { group.Children.Add(new ConditionNode { Kind = "Or", Children = [NewAttributeLeaf()] }); changed = true; } + ImGui.Unindent(); + + ImGui.EndChild(); + if (isActive) ImGui.PopStyleColor(); + return changed; + } + + // Builds the "Move to" submenu listing every group except the node's own subtree. + private static void DrawMoveMenu(ConditionNode node, ConditionNode root) + { + if (!ImGui.BeginMenu("Move to")) return; + var groups = ConditionTreeOps.Groups(root); + for (var gi = 0; gi < groups.Count; gi++) + { + var g = groups[gi]; + if (ConditionTreeOps.Contains(node, g)) continue; // self or descendant + var label = (ReferenceEquals(g, root) ? "root" : (g.Kind == "Or" ? "Any of" : "All of")) + $" #{gi}"; + if (ImGui.MenuItem(label)) { _op = OpKind.Move; _opNode = node; _opDest = g; } + } + ImGui.EndMenu(); + } + + private static bool DrawLeaf(ConditionNode leaf, ConditionNode root) + { + if (leaf.Raw != null) return DrawRawLeaf(leaf, root); + + var changed = false; + Controls.StatusDot(true); + ImGui.SameLine(); + + if (leaf.Mod != null) changed |= DrawModFields(leaf); + else changed |= DrawAttrFields(leaf); + + ImGui.SameLine(); + if (ImGui.SmallButton("menu")) ImGui.OpenPopup("leafmenu"); + if (ImGui.BeginPopup("leafmenu")) + { + if (ImGui.MenuItem("Wrap in AND")) { _op = OpKind.WrapAnd; _opNode = leaf; } + if (ImGui.MenuItem("Wrap in OR")) { _op = OpKind.WrapOr; _opNode = leaf; } + DrawMoveMenu(leaf, root); + if (ImGui.MenuItem("Delete")) { _op = OpKind.Delete; _opNode = leaf; } + ImGui.EndPopup(); + } + return changed; + } + + private static bool DrawRawLeaf(ConditionNode leaf, ConditionNode root) + { + var changed = false; + Controls.StatusDot(null); + ImGui.SameLine(); + ImGui.TextColored(Controls.Warn, "raw"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(220); + var v = leaf.Raw ?? ""; + if (ImGui.InputText("##raw", ref v, 500)) { leaf.Raw = v; changed = true; } + ImGui.SameLine(); + if (ImGui.SmallButton("try parse")) { _op = OpKind.Replace; _opNode = leaf; _opDest = QueryParser.Parse(leaf.Raw ?? ""); } + ImGui.SameLine(); + if (ImGui.SmallButton("menu")) ImGui.OpenPopup("rawmenu"); + if (ImGui.BeginPopup("rawmenu")) + { + DrawMoveMenu(leaf, root); + if (ImGui.MenuItem("Delete")) { _op = OpKind.Delete; _opNode = leaf; } + ImGui.EndPopup(); + } + return changed; + } + + private static bool DrawModFields(ConditionNode leaf) + { + var changed = false; + ImGui.TextColored(Controls.CategoryColor(ChipCategory.Mod), $"mod: {leaf.Mod.Display}"); + ImGui.SameLine(); + var hasMode = leaf.Mod.Mode == "Has"; + if (ImGui.Checkbox("has-only", ref hasMode)) { leaf.Mod.Mode = hasMode ? "Has" : "Value"; changed = true; } + if (leaf.Mod.Mode == "Value") + { + ImGui.SameLine(); + ImGui.SetNextItemWidth(120); + var t = leaf.Mod.Threshold; + var min = leaf.Mod.Min ?? 0; + var max = leaf.Mod.Max ?? (t > 0 ? t * 2 : 100); + if (ImGui.SliderInt("##thr", ref t, min, max)) { leaf.Mod.Threshold = t; changed = true; } + } + if (leaf.Mod.GameStats is { Length: > 0 }) + { + ImGui.SameLine(); + var stat = leaf.Mod.MatchByStat; + if (ImGui.Checkbox("by stat", ref stat)) { leaf.Mod.MatchByStat = stat; changed = true; } + } + return changed; + } + + private static bool DrawAttrFields(ConditionNode leaf) + { + var changed = false; + var defs = AttributeCatalog.All; + var attrIdx = Math.Max(0, defs.FindIndex(d => d.Key == leaf.Attribute)); + var attrNames = defs.Select(d => d.Key).ToArray(); + + ImGui.SetNextItemWidth(130); + if (ImGui.Combo("##attr", ref attrIdx, attrNames, attrNames.Length)) + { + leaf.Attribute = defs[attrIdx].Key; + leaf.Operator = defs[attrIdx].Operators[0]; + changed = true; + } + var def = defs[attrIdx]; + + ImGui.SameLine(); + var opIdx = Math.Max(0, Array.IndexOf(def.Operators, leaf.Operator)); + ImGui.SetNextItemWidth(90); + if (ImGui.Combo("##op", ref opIdx, def.Operators, def.Operators.Length)) + { leaf.Operator = def.Operators[opIdx]; changed = true; } + + if (def.Kind != AttrKind.Bool) + { + ImGui.SameLine(); + ImGui.SetNextItemWidth(140); + if (def.Kind == AttrKind.Enum) + { + var values = def.EnumValues ?? []; + var vIdx = Array.IndexOf(values, leaf.Value); + // Freshly added or just-switched-to-enum conditions carry Value "" (or a stale + // non-enum string), so the combo shows EnumValues[0] while Value stays empty and + // the query compiles to "ItemRarity." with no member. Persist the displayed + // default so it's a real value; this also self-heals such conditions in old + // configs the moment they're opened in the builder. + if (vIdx < 0 && values.Length > 0) + { + vIdx = 0; + leaf.Value = values[0]; + changed = true; + } + if (values.Length > 0 && ImGui.Combo("##val", ref vIdx, values, values.Length)) + { leaf.Value = values[vIdx]; changed = true; } + } + else + { + var v = leaf.Value ?? ""; + if (ImGui.InputText("##val", ref v, 200)) { leaf.Value = v; changed = true; } + } + } + return changed; + } +} diff --git a/Compartments/FilterBuilderEditor.cs b/Compartments/FilterBuilderEditor.cs new file mode 100644 index 0000000..29b69e5 --- /dev/null +++ b/Compartments/FilterBuilderEditor.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ImGuiNET; +using ItemFilterLibrary; +using Stashie.Classes; +using Stashie.Ui; +using static Stashie.StashieCore; +using Vector2N = System.Numerics.Vector2; + +namespace Stashie.Compartments; + +public static class FilterBuilderEditor +{ + private static int _selectedGroup = -1; + private static int _selectedFilter = -1; + private static bool _rawMode; + private static string _chipSearch = ""; + private static bool _catalogPruned; + + public static void EnsureIds() + { + var parent = Main.Settings.CurrentFilterOptions?.ParentMenu; + if (parent == null) return; + foreach (var g in parent) + foreach (var f in g.Filters) + if (string.IsNullOrEmpty(f.Id)) + f.Id = Guid.NewGuid().ToString("N"); + } + + // Entry point used by DrawSettings. Wraps the three-pane editor in the scoped theme. + public static void Draw() + { + if (!_catalogPruned) { AttributeCatalog.ProbeAndPrune(); _catalogPruned = true; } + EnsureIds(); + var parent = Main.Settings.CurrentFilterOptions?.ParentMenu; + if (parent == null) return; + + StashieTheme.Push(); + try + { + // Each column is its own scroll container so a long groups/filters list scrolls + // independently instead of pushing the whole window (which would scroll the filter + // and scanner panes out of view). Bound the panes to the remaining visible height; + // height 0 children would expand to content and re-couple the scrolling, so use an + // explicit height. Reserve one line for the pinned section header drawn above each + // pane, and floor it so a tiny/near-bottom window can't collapse the panes. + var headerHeight = ImGui.GetTextLineHeightWithSpacing(); + var bodyHeight = ImGui.GetContentRegionAvail().Y - headerHeight; + if (bodyHeight < 150f) bodyHeight = 150f; + var paneSize = new Vector2N(0, bodyHeight); + + ImGui.Columns(3, "stashie_editor_cols", true); + + ImGui.TextDisabled("GROUPS / FILTERS"); + if (ImGui.BeginChild("col_tree", paneSize, ImGuiChildFlags.None)) + DrawTree(parent); + ImGui.EndChild(); + ImGui.NextColumn(); + + ImGui.TextDisabled("FILTER"); + if (ImGui.BeginChild("col_builder", paneSize, ImGuiChildFlags.None)) + DrawBuilder(parent); + ImGui.EndChild(); + ImGui.NextColumn(); + + ImGui.TextDisabled("SCANNER"); + if (ImGui.BeginChild("col_scanner", paneSize, ImGuiChildFlags.None)) + DrawScanner(); + ImGui.EndChild(); + + ImGui.Columns(1); + } + finally + { + StashieTheme.Pop(); + } + } + + // Group headers are tinted so they read as containers; filters stay default-colored and indented. + private static readonly System.Numerics.Vector4 GroupColor = new(0.55f, 0.78f, 1f, 1f); + + private static void DrawTree(List parent) + { + for (var gi = 0; gi < parent.Count; gi++) + { + ImGui.PushID(gi); + var group = parent[gi]; + var groupLabel = string.IsNullOrWhiteSpace(group.MenuName) ? $"Group {gi + 1}" : group.MenuName; + var filterCount = group.Filters?.Count ?? 0; + + ImGui.PushStyleColor(ImGuiCol.Text, GroupColor); + var open = ImGui.TreeNodeEx($"{groupLabel} ({filterCount})", ImGuiTreeNodeFlags.DefaultOpen); + ImGui.PopStyleColor(); + + ImGui.SameLine(); + if (ImGui.ArrowButton("gup", ImGuiDir.Up) && gi > 0) + { + (parent[gi - 1], parent[gi]) = (parent[gi], parent[gi - 1]); + if (_selectedGroup == gi) _selectedGroup = gi - 1; + else if (_selectedGroup == gi - 1) _selectedGroup = gi; + if (open) ImGui.TreePop(); + ImGui.PopID(); + break; + } + ImGui.SameLine(); + if (ImGui.ArrowButton("gdown", ImGuiDir.Down) && gi < parent.Count - 1) + { + (parent[gi + 1], parent[gi]) = (parent[gi], parent[gi + 1]); + if (_selectedGroup == gi) _selectedGroup = gi + 1; + else if (_selectedGroup == gi + 1) _selectedGroup = gi; + if (open) ImGui.TreePop(); + ImGui.PopID(); + break; + } + ImGui.SameLine(); + if (ImGui.SmallButton("x##delgroup")) + { + parent.RemoveAt(gi); + if (_selectedGroup == gi) { _selectedGroup = -1; _selectedFilter = -1; } + else if (_selectedGroup > gi) _selectedGroup--; + if (open) ImGui.TreePop(); + ImGui.PopID(); + break; + } + + if (open) + { + group.Filters ??= []; + ImGui.Indent(); + for (var fi = 0; fi < group.Filters.Count; fi++) + { + ImGui.PushID(fi); + var f = group.Filters[fi]; + + if (ImGui.ArrowButton("fup", ImGuiDir.Up) && fi > 0) + { + (group.Filters[fi - 1], group.Filters[fi]) = (group.Filters[fi], group.Filters[fi - 1]); + if (_selectedGroup == gi && _selectedFilter == fi) _selectedFilter = fi - 1; + else if (_selectedGroup == gi && _selectedFilter == fi - 1) _selectedFilter = fi; + ImGui.PopID(); + break; + } + ImGui.SameLine(); + if (ImGui.ArrowButton("fdown", ImGuiDir.Down) && fi < group.Filters.Count - 1) + { + (group.Filters[fi + 1], group.Filters[fi]) = (group.Filters[fi], group.Filters[fi + 1]); + if (_selectedGroup == gi && _selectedFilter == fi) _selectedFilter = fi + 1; + else if (_selectedGroup == gi && _selectedFilter == fi + 1) _selectedFilter = fi; + ImGui.PopID(); + break; + } + ImGui.SameLine(); + if (ImGui.SmallButton("x")) + { + group.Filters.RemoveAt(fi); + if (_selectedGroup == gi && _selectedFilter == fi) _selectedFilter = -1; + else if (_selectedGroup == gi && _selectedFilter > fi) _selectedFilter--; + ImGui.PopID(); + break; + } + + ImGui.SameLine(); + var label = string.IsNullOrWhiteSpace(f.FilterName) ? $"Filter {fi + 1}" : f.FilterName; + if (ImGui.Selectable(label, _selectedGroup == gi && _selectedFilter == fi)) + { + _selectedGroup = gi; + _selectedFilter = fi; + // Round-trip: if there's a raw query but no usable tree (legacy/hand-written), + // parse it so the builder is editable. Otherwise keep the stored tree, then + // normalize to a group root so it can be edited/appended (see EnsureGroupRoot). + if (!HasConditions(f.Conditions) && !string.IsNullOrEmpty(f.RawQuery)) + f.Conditions = QueryParser.Parse(f.RawQuery); + f.Conditions = EnsureGroupRoot(f.Conditions); + _rawMode = false; + ConditionTreeEditor.ActiveGroup = f.Conditions; + } + ImGui.PopID(); + } + if (ImGui.SmallButton("+ filter")) + { + group.Filters.Add(new FilterEditor.Filter { Id = Guid.NewGuid().ToString("N"), + FilterName = "", RawQuery = "", Conditions = new ConditionNode { Kind = "And" } }); + } + ImGui.Unindent(); + ImGui.TreePop(); + } + ImGui.PopID(); + } + if (ImGui.Button("+ group")) + { + parent.Add(new FilterEditor.ParentMenu { MenuName = "", Filters = [] }); + } + } + + private static FilterEditor.Filter Selected(List parent) + { + if (_selectedGroup < 0 || _selectedGroup >= parent.Count) return null; + var g = parent[_selectedGroup]; + if (_selectedFilter < 0 || _selectedFilter >= g.Filters.Count) return null; + return g.Filters[_selectedFilter]; + } + + // A structured tree is "usable" only if it's a leaf or a group with children. An empty + // group ({Kind:"And", Children:[]}) counts as absent so a RawQuery-only filter opens in raw. + private static bool HasConditions(ConditionNode n) => + n != null && (n.Kind == "Leaf" || (n.Children != null && n.Children.Count > 0)); + + // The builder's "+ condition"/"+ group" controls and the scanner '+' both add into a *group* + // root. But a single-condition query (legacy or hand-written) parses to a bare Leaf, and a + // stored tree can be a Leaf/Raw root too. With a leaf root the tree offers no way to add + // siblings, "Wrap in AND" on the root is a no-op (the root has no parent), and appending puts + // children on a Leaf — which QueryCompiler.CompileLeaf ignores, so the addition is silently + // lost. Wrap any non-group root in an And so it's always an editable container; this also + // self-heals such trees in old configs the moment they're selected. Compile({And,[x]}) == + // Compile(x), so the engine query (RawQuery) is unchanged. EnsureGroupRoot(null) yields an + // empty And, covering the no-tree case. + private static ConditionNode EnsureGroupRoot(ConditionNode node) + { + if (node == null) return new ConditionNode { Kind = "And" }; + if (node.Kind == "And" || node.Kind == "Or") return node; + return new ConditionNode { Kind = "And", Children = [node] }; + } + + // ImGui.Separator() spans the whole window inside the legacy Columns layout, bleeding lines + // across the other panes. This draws a divider limited to the current column's width. + private static void ColumnSeparator() + { + ImGui.Spacing(); + var dl = ImGui.GetWindowDrawList(); + var p = ImGui.GetCursorScreenPos(); + var w = ImGui.GetContentRegionAvail().X; + dl.AddLine(p, new Vector2N(p.X + w, p.Y), ImGui.GetColorU32(ImGuiCol.Separator)); + ImGui.Dummy(new Vector2N(0, 3)); + } + + private static void Recompile(FilterEditor.Filter f) + { + if (f.Conditions == null) return; + var q = QueryCompiler.Compile(f.Conditions); + var (ok, _) = QueryCompiler.Validate(q); + if (ok) f.RawQuery = q; // keep last-good on failure + f._lastCompileOk = ok; + f._lastCompiled = q; + } + + private static void DrawBuilder(List parent) + { + var f = Selected(parent); + if (f == null) { ImGui.TextWrapped("Select a filter on the left, or add one."); return; } + + var group = parent[_selectedGroup]; + var gname = group.MenuName ?? ""; + if (ImGui.InputText("Group name", ref gname, 200)) group.MenuName = gname; + + var name = f.FilterName ?? ""; + if (ImGui.InputText("Filter name", ref name, 200)) f.FilterName = name; + + // Stash tab combo (id-keyed). Names come from StashTabNamesByIndex (built elsewhere). + var names = Main.StashTabNamesByIndex ?? []; + if (!Main.Settings.FilterTabById.TryGetValue(f.Id, out var node)) + { + node = new ListIndexNode { Value = "Ignore", Index = -1 }; + Main.Settings.FilterTabById[f.Id] = node; + } + var sel = node.Index + 1; + if (names.Length > 0 && ImGui.Combo("Stash tab", ref sel, names, names.Length)) + { + node.Index = sel - 1; + node.Value = names[sel]; + } + + ImGui.Checkbox("Affinity", ref f.Affinity); + ImGui.SameLine(); + ImGui.Checkbox("Shifting", ref f.Shifting); + + ImGui.SameLine(); + if (ImGui.Button(_rawMode ? "Builder" : " Raw")) + { + if (_rawMode) { f.Conditions = QueryParser.Parse(f.RawQuery ?? ""); Recompile(f); } + _rawMode = !_rawMode; + } + + ColumnSeparator(); + + if (_rawMode) + { + ImGui.TextDisabled("Edit the compiled query directly. 'Builder' re-parses it into the tree."); + var raw = f.RawQuery ?? ""; + if (ImGui.InputTextMultiline("##raw", ref raw, 15000, new Vector2N(0, 120), + ImGuiInputTextFlags.AllowTabInput)) + { + f.RawQuery = raw; // tree is rebuilt from this when leaving raw mode + } + return; + } + + if (ConditionTreeEditor.Draw(f.Conditions = EnsureGroupRoot(f.Conditions))) + Recompile(f); + ColumnSeparator(); + ImGui.TextDisabled("PREVIEW"); + ImGui.TextWrapped(f._lastCompiled ?? f.RawQuery ?? ""); + if (f._lastCompileOk == false) + ImGui.TextColored(new System.Numerics.Vector4(1, 0.4f, 0.4f, 1), "query failed to compile — last good kept"); + } + + private static void DrawScanner() + { + ImGui.TextWrapped($"Hover an item and press [{Main.Settings.ScannerGrabHotkey.Value}] to grab it."); + if (ImGui.SmallButton($"Clear pool ({ItemScanner.Pool.Count})")) ItemScanner.ClearPool(); + + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##chipsearch", "search...", ref _chipSearch, 100); + + var parent = Main.Settings.CurrentFilterOptions?.ParentMenu; + var canAdd = parent != null && Selected(parent) != null; + if (!canAdd) + ImGui.TextColored(new System.Numerics.Vector4(1f, 0.7f, 0.3f, 1f), "select a filter to add to"); + + var sel = parent != null ? Selected(parent) : null; + if (sel?.Conditions != null) + { + ConditionTreeEditor.ValidateActive(sel.Conditions); + var g = ConditionTreeEditor.ActiveGroup; + var word = g == sel.Conditions ? "root" : (g.Kind == "Or" ? "Any of (OR)" : "All of (AND)"); + ImGui.TextDisabled("adding to:"); + ImGui.SameLine(); + ImGui.TextColored(Controls.Accent, word); + if (g != sel.Conditions && ImGui.SmallButton("^ root")) ConditionTreeEditor.ActiveGroup = sel.Conditions; + } + + ColumnSeparator(); + ImGui.TextDisabled("GRABBED ITEMS"); + if (ItemScanner.Pool.Count == 0) + ImGui.TextWrapped("Pool is empty. Hover an item in-game and press the grab key."); + + for (var i = 0; i < ItemScanner.Pool.Count; i++) + { + ImGui.PushID(i); + DrawGrabbedCard(ItemScanner.Pool[i], canAdd, out var remove); + ImGui.PopID(); + if (remove) { ItemScanner.Pool.RemoveAt(i); break; } + } + + ColumnSeparator(); + if (ImGui.CollapsingHeader("Inventory (auto-scan)")) + { + List chips; + try { chips = ItemScanner.BuildInventoryChips(); } + catch (Exception ex) { ImGui.TextWrapped($"scan error: {ex.Message}"); return; } + DrawChipList(chips, canAdd); + } + } + + // One bordered card per grabbed item: header + attribute quick-adds + mod rows. + private static void DrawGrabbedCard(ItemData item, bool canAdd, out bool remove) + { + remove = false; + + // ChipsForItem reads each section of the live ItemData defensively and won't throw; an + // item the game hasn't streamed into memory yet simply yields fewer (or no) chips and + // fills in on a later frame as the live data resolves (e.g. after pickup). + List chips; + try { chips = ItemScanner.ChipsForItem(item).ToList(); } + catch { chips = []; } + + var className = chips.FirstOrDefault(c => c.Category == ChipCategory.Class)?.Display; + var baseName = chips.FirstOrDefault(c => c.Category == ChipCategory.Base)?.Display; + var header = !string.IsNullOrEmpty(baseName) && !string.IsNullOrEmpty(className) + ? $"{baseName} - {className}" + : (string.IsNullOrEmpty(baseName) ? className : baseName); + + ImGui.BeginChild("card", new Vector2N(0, 0), ImGuiChildFlags.Border | ImGuiChildFlags.AutoResizeY); + + ImGui.PushStyleColor(ImGuiCol.Text, GroupColor); + ImGui.TextUnformatted(string.IsNullOrEmpty(header) ? "(item)" : header); + ImGui.PopStyleColor(); + ImGui.SameLine(); + if (ImGui.SmallButton("x")) remove = true; + + // Nothing readable yet: the game hasn't loaded this item's data. The card self-heals + // once it does — pick the item up or move closer in-game to force the load. + if (chips.Count == 0) + { + ImGui.TextDisabled("loading item data... (pick it up / move closer in-game)"); + ImGui.EndChild(); + return; + } + + // Info line: rarity + flags + sockets as text. + var meta = chips.Where(c => c.Category is ChipCategory.Rarity or ChipCategory.Flag or ChipCategory.Socket) + .Select(c => c.Display).ToList(); + if (meta.Count > 0) ImGui.TextDisabled(string.Join(" - ", meta)); + + // Attribute quick-adds (Class, Base, Rarity, flags, sockets), one row each. + foreach (var c in chips.Where(c => c.Category != ChipCategory.Mod && MatchesSearch(c))) + { + ImGui.PushID(c.DedupKey); + if (AddButton("+", canAdd)) AddChipToSelected(c); + ImGui.SameLine(); + ImGui.TextUnformatted(AttrLabel(c)); + ImGui.PopID(); + } + + var mods = chips.Where(c => c.Category == ChipCategory.Mod && MatchesSearch(c)).ToList(); + if (mods.Count > 0) ImGui.TextDisabled("mods"); + foreach (var c in mods) + { + ImGui.PushID(c.DedupKey); + if (AddButton("+", canAdd)) AddChipToSelected(c); + ImGui.SameLine(); + ImGui.TextUnformatted(c.Display); + if (c.Mod is { Mode: "Value" }) + { + ImGui.SameLine(); + ImGui.TextDisabled($">= {c.Mod.Threshold}"); + } + ImGui.PopID(); + } + + ImGui.EndChild(); + } + + // Flat categorized chip list, used for the collapsed inventory section. + private static void DrawChipList(List chips, bool canAdd) + { + ChipCategory? last = null; + foreach (var chip in chips) + { + if (!MatchesSearch(chip)) continue; + if (last != chip.Category) + { + ImGui.TextDisabled(chip.Category.ToString()); + last = chip.Category; + } + ImGui.PushID(chip.DedupKey); + if (AddButton(chip.Display, canAdd)) AddChipToSelected(chip); + ImGui.PopID(); + } + } + + private static bool AddButton(string label, bool enabled) + { + if (!enabled) ImGui.BeginDisabled(); + var clicked = ImGui.SmallButton(label); + if (!enabled) ImGui.EndDisabled(); + return clicked && enabled; + } + + private static bool MatchesSearch(Chip c) => + string.IsNullOrEmpty(_chipSearch) || + (c.Display ?? "").Contains(_chipSearch, StringComparison.OrdinalIgnoreCase); + + private static string AttrLabel(Chip c) => c.Category switch + { + ChipCategory.Class => $"Class: {c.Display}", + ChipCategory.Base => $"Base: {c.Display}", + ChipCategory.Rarity => $"Rarity: {c.Display}", + _ => c.Display + }; + + private static void AddChipToSelected(Chip chip) + { + var parent = Main.Settings.CurrentFilterOptions?.ParentMenu; + if (parent == null) return; + var f = Selected(parent); + if (f == null) return; + f.Conditions = EnsureGroupRoot(f.Conditions); // never append onto a bare-leaf root + ConditionTreeEditor.ValidateActive(f.Conditions); + var target = ConditionTreeEditor.ActiveGroup ?? f.Conditions; + + var leaf = chip.Mod != null + ? new ConditionNode { Kind = "Leaf", Mod = chip.Mod } + : new ConditionNode { Kind = "Leaf", Attribute = chip.Attribute, Operator = chip.Operator, Value = chip.Value }; + + target.Children.Add(leaf); + _rawMode = false; + Recompile(f); + } +} diff --git a/Compartments/FilterManager.cs b/Compartments/FilterManager.cs index e704d15..59f8978 100644 --- a/Compartments/FilterManager.cs +++ b/Compartments/FilterManager.cs @@ -38,20 +38,46 @@ public static void LoadCustomFilters() { Main.currentFilter = FilterFileHandler.Load($"{Main.Settings.FilterFile.Value}.json", filterFilePath); + // Rebuilt from scratch below, so clear first — otherwise repeated reloads (manual + // Save and now debounced auto-save) keep appending the same nodes and the list the + // stash-name coroutine iterates grows unbounded. The node objects persist via + // FilterTabById / filter.StashIndexNode, so only the flat lookup list is reset. + Main.SettingsListNodes?.Clear(); + foreach (var customFilter in Main.currentFilter) foreach (var filter in customFilter.Filters) { - if (!Main.Settings.CustomFilterOptions.TryGetValue(customFilter.ParentMenuName + filter.FilterName, - out var indexNodeS)) + ListIndexNode indexNodeS; + var byId = Main.Settings.FilterTabById; + // CustomFilter.Filter has no Id; match by name back to the editor model to find the Id. + var editorFilter = Main.Settings.CurrentFilterOptions.ParentMenu + .FirstOrDefault(p => p.MenuName == customFilter.ParentMenuName)?.Filters + .FirstOrDefault(ef => ef.FilterName == filter.FilterName); + + if (editorFilter != null && !string.IsNullOrEmpty(editorFilter.Id) && + byId.TryGetValue(editorFilter.Id, out var idNode)) + { + indexNodeS = idNode; + } + else if (!Main.Settings.CustomFilterOptions.TryGetValue( + customFilter.ParentMenuName + filter.FilterName, out indexNodeS)) { indexNodeS = new ListIndexNode { Value = "Ignore", Index = -1 }; - Main.Settings.CustomFilterOptions.Add(customFilter.ParentMenuName + filter.FilterName, - indexNodeS); + Main.Settings.CustomFilterOptions.Add(customFilter.ParentMenuName + filter.FilterName, indexNodeS); } filter.StashIndexNode = indexNodeS; Main.SettingsListNodes.Add(indexNodeS); } + + FilterBuilderEditor.EnsureIds(); + if (Main.Settings.FilterTabById.Count == 0 && Main.Settings.CustomFilterOptions.Count > 0) + { + Main.Settings.FilterTabById = TabAssignmentMigration.Migrate( + Main.Settings.CurrentFilterOptions, + Main.Settings.CustomFilterOptions, + n => new ListIndexNode { Value = n.Value, Index = n.Index }); + } } else { diff --git a/Compartments/ItemScanner.cs b/Compartments/ItemScanner.cs new file mode 100644 index 0000000..c8d271f --- /dev/null +++ b/Compartments/ItemScanner.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExileCore2; // Input +using ExileCore2.PoEMemory; // Element +using ExileCore2.PoEMemory.Elements.InventoryElements; // NormalInventoryItem +using ExileCore2.PoEMemory.MemoryObjects; // Entity, ItemMod +using ExileCore2.Shared.Enums; // ItemRarity +using ItemFilterLibrary; // ItemData +using Stashie.Classes; +using static Stashie.StashieCore; + +namespace Stashie.Compartments; + +public enum ChipCategory { Class, Base, Rarity, Flag, Socket, Mod } + +public class Chip +{ + public ChipCategory Category; + public string Display; // shown on the chip + public string Attribute; // catalog key for non-mod chips (e.g. "ClassName") + public string Operator; // default operator for non-mod chips + public string Value; // default value for non-mod chips + public ModRef Mod; // populated for mod chips + public string DedupKey; // category + identity +} + +public static class ItemScanner +{ + // Session-only pool of grabbed items (kept as ItemData snapshots). + public static readonly List Pool = []; + + public static bool _grabKeyWasDown; + + public static void ClearPool() => Pool.Clear(); + + // Edge-detected grab; call from Tick. Reads the hovered inventory item (reusing + // DynamicIgnore) and adds an ItemData snapshot to the pool. + public static void HandleGrabHotkey() + { + var key = Main.Settings.ScannerGrabHotkey.Value; + var isDown = Input.IsKeyDown(key); + var pressed = isDown && !_grabKeyWasDown; + _grabKeyWasDown = isDown; + if (!pressed) return; + + var hovered = DynamicIgnore.GetHoveredInventoryItem(); + if (hovered?.Item != null) + { + TryPool(hovered.Item); + return; + } + + // Fallback: non-inventory hovered element (stash/ground) via UIHover. + TryPool(TryGetEntity(Main.GameController?.IngameState?.UIHover)); + } + + // Only pool entities that resolve to a real item. A non-item hover (e.g. a UI + // element reinterpreted as an inventory item) yields an ItemData whose lazy stat + // aggregation throws "duplicate DummyStatDisplayNothing" when chips are read, which + // would otherwise blank the scanner — so reject it here. + private static void TryPool(Entity ent) + { + if (ent == null || ent.Address == 0) return; + try + { + var data = new ItemData(ent, Main.GameController); + if (string.IsNullOrEmpty(data.Path) || !data.Path.Contains("Metadata/Items")) return; + Pool.Add(data); + } + catch + { + // not a valid item + } + } + + private static Entity TryGetEntity(Element ui) + { + if (ui == null || ui.Address == 0) return null; + try + { + var invItem = ui.AsObject(); + return invItem?.Item; + } + catch + { + return null; + } + } + + // Deduped chips from the player's inventory only (the grabbed pool renders as cards). + public static List BuildInventoryChips() + { + var items = new List(); + try { items.AddRange(FilterManager.GetInventoryItems()); } catch { } + return Dedup(items); + } + + private static List Dedup(IEnumerable items) + { + var byKey = new Dictionary(); + foreach (var item in items) + { + if (item == null) continue; + try + { + // Per-item isolation: a single unreadable item (transient memory read, + // or IFL throwing on a malformed/dummy item) must not blank the whole pane. + foreach (var chip in ChipsForItem(item)) + byKey[chip.DedupKey] = chip; + } + catch + { + // skip this item, keep the chips already collected + } + } + + return byKey.Values + .OrderBy(c => c.Category) + .ThenBy(c => c.Display) + .ToList(); + } + + // All chips for a single item, in render order (Class, Base, Rarity, flags, sockets, then mods). + // Each section reads the live ItemData defensively: a freshly-encountered item whose Mods + // component the game hasn't streamed into memory yet throws on the mod/rarity/flag reads. + // Isolating each section means such a failure degrades only that section — the readable + // sections still render, and the live ItemData fills in the rest on a later frame once the + // game loads it (the same reason the card resolves after you pick the item up in-game). + internal static IEnumerable ChipsForItem(ItemData item) + { + var className = TryRead(() => item.ClassName); + if (!string.IsNullOrEmpty(className)) + yield return new Chip { Category = ChipCategory.Class, Display = className, + Attribute = "ClassName", Operator = "==", Value = className, + DedupKey = "Class:" + className }; + + var baseName = TryRead(() => item.BaseName); + if (!string.IsNullOrEmpty(baseName)) + yield return new Chip { Category = ChipCategory.Base, Display = baseName, + Attribute = "BaseName", Operator = "==", Value = baseName, + DedupKey = "Base:" + baseName }; + + // Rarity lives on the Mods component (like the mod list), so it resolves on the same + // frame the mods do; Unknown means "not loaded / not applicable" — emit nothing then. + // Only emit for the rarities the catalog supports (Normal/Magic/Rare/Unique, probe- + // validated against the live IFL) so the "+" always compiles to a valid query; gems, + // currency, etc. get no rarity chip (filter those by ClassName instead). + var rarity = TryRead(() => item.Rarity, ItemRarity.Unknown); + var rarityName = rarity.ToString(); + if (rarity != ItemRarity.Unknown && + AttributeCatalog.Find("Rarity")?.EnumValues?.Contains(rarityName) == true) + yield return new Chip { Category = ChipCategory.Rarity, Display = rarityName, + Attribute = "Rarity", Operator = "==", Value = rarityName, + DedupKey = "Rarity:" + rarityName }; + + List<(string key, string display)> flags; + try { flags = BoolFlags(item).ToList(); } catch { flags = []; } + foreach (var (key, display) in flags) + yield return new Chip { Category = ChipCategory.Flag, Display = display, + Attribute = key, Operator = "has", Value = null, DedupKey = "Flag:" + key }; + + // PoE2 SocketData exposes SocketNumber only (no link size); emit a socket-count chip. + var socket = TryRead(() => item.SocketInfo); + if (socket != null && socket.SocketNumber > 0) + yield return new Chip { Category = ChipCategory.Socket, Display = $"{socket.SocketNumber} sockets", + Attribute = "Sockets", Operator = ">=", Value = socket.SocketNumber.ToString(), + DedupKey = "Socket:n" + socket.SocketNumber }; + + List mods; + try { mods = ModChips(item).ToList(); } catch { mods = []; } + foreach (var chip in mods) + yield return chip; + } + + // Reads one live ItemData property in isolation. A transient memory-read failure (component + // not yet streamed, or IFL throwing on a malformed item) yields the fallback instead of + // throwing, so one unreadable section never discards the rest of the item's chips. + private static T TryRead(Func read, T fallback = default) + { + try { return read(); } + catch { return fallback; } + } + + private static IEnumerable<(string key, string display)> BoolFlags(ItemData item) + { + if (item.IsCorrupted) yield return ("IsCorrupted", "Corrupted"); + if (item.IsIdentified) yield return ("IsIdentified", "Identified"); + if (item.IsWeapon) yield return ("IsWeapon", "Weapon"); + if (item.Enchanted) yield return ("Enchanted", "Enchanted"); + } + + private static IEnumerable ModChips(ItemData item) + { + // Resolve the item's mods to ItemMod via FindMods over the known mod names. + var seen = new HashSet(); + var names = item.ModsNames ?? new List(); + foreach (var name in names) + { + List mods; + try { mods = item.FindMods(name); } catch { continue; } + if (mods == null) continue; + + foreach (var mod in mods) + { + var token = !string.IsNullOrEmpty(mod.Group) ? mod.Group : mod.RawName ?? mod.Name; + if (string.IsNullOrEmpty(token)) continue; + // Skip placeholder/display-only stats (e.g. "DummyStatDisplayNothing"). + if (token.IndexOf("Dummy", StringComparison.OrdinalIgnoreCase) >= 0) continue; + if (!seen.Add("Mod:" + token)) continue; + + var display = !string.IsNullOrEmpty(mod.Translation) ? mod.Translation + : !string.IsNullOrEmpty(mod.DisplayName) ? mod.DisplayName : token; + + int? min = null, max = null; + if (mod.ValuesMinMax is { Length: > 0 }) + { + min = mod.ValuesMinMax[0].Min; + max = mod.ValuesMinMax[0].Max; + } + var rolled = mod.Values is { Count: > 0 } ? mod.Values[0] : 0; + + yield return new Chip + { + Category = ChipCategory.Mod, + Display = display, + DedupKey = "Mod:" + token, + Mod = new ModRef + { + Display = display, + MatchToken = token, + Mode = "Value", + ValueIndex = 0, + Threshold = rolled, + Min = min, + Max = max, + GameStats = ResolveGameStats(mod), + } + }; + } + } + } + + // Best-effort: resolve a mod's GameStat names for stat-mode. Ships returning [] + // (stat-mode disabled per mod) until the exact ModRecord stat accessor is confirmed + // in-game; this is the spec's graceful-degradation path (§9). + private static string[] ResolveGameStats(ItemMod mod) + { + return System.Array.Empty(); + } +} diff --git a/Compartments/QueryCompiler.cs b/Compartments/QueryCompiler.cs new file mode 100644 index 0000000..84f1fb4 --- /dev/null +++ b/Compartments/QueryCompiler.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using Stashie.Classes; + +namespace Stashie.Compartments; + +public static class QueryCompiler +{ + public static string Compile(ConditionNode node) + { + if (node == null) return ""; + + if (node.Kind == "And" || node.Kind == "Or") + { + var sep = node.Kind == "And" ? " && " : " || "; + var parts = node.Children + .Select(c => WrapChild(c, node.Kind)) + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + return string.Join(sep, parts); + } + + return CompileLeaf(node); + } + + // Validity check via the same loader the engine uses. Returns (ok, error). + public static (bool ok, string error) Validate(string rawQuery) + { + if (string.IsNullOrWhiteSpace(rawQuery)) return (false, "empty query"); + try + { + var q = ItemFilterLibrary.ItemQuery.Load(rawQuery); + return (!q.FailedToCompile, q.FailedToCompile ? "failed to compile" : null); + } + catch (System.Exception ex) + { + return (false, ex.Message); + } + } + + // A child group needs parentheses so precedence is unambiguous; leaves never do. + private static string WrapChild(ConditionNode child, string parentKind) + { + var s = Compile(child); + if (string.IsNullOrEmpty(s)) return s; + var isGroup = child.Kind == "And" || child.Kind == "Or"; + return isGroup ? $"({s})" : s; + } + + private static string CompileLeaf(ConditionNode n) + { + if (!string.IsNullOrEmpty(n.Raw)) return n.Raw; + if (n.Mod != null) return CompileMod(n.Mod); + + var def = AttributeCatalog.Find(n.Attribute); + if (def == null) return ""; + + switch (def.Kind) + { + case AttrKind.String: + return n.Operator == "contains" + ? $"{def.Dsl}.Contains(\"{Escape(n.Value)}\")" + : $"{def.Dsl} {n.Operator} \"{Escape(n.Value)}\""; + case AttrKind.Number: + return $"{def.Dsl} {n.Operator} {n.Value}"; + case AttrKind.Enum: + return $"{def.Dsl} {n.Operator} {def.EnumType}.{n.Value}"; + case AttrKind.Bool: + return n.Operator == "notHas" ? $"!{def.Dsl}" : def.Dsl; + default: + return ""; + } + } + + private static string CompileMod(ModRef m) + { + if (m.MatchByStat) + { + var stat = m.GameStats != null && m.GameStats.Length > 0 ? m.GameStats[0] : ""; + return $"ItemStats.GetValueOrDefault(GameStat.{stat}) >= {m.Threshold}"; + } + + if (m.Mode == "Has") + return $"FindMods(\"{Escape(m.MatchToken)}\").Any()"; + + return $"FindMods(\"{Escape(m.MatchToken)}\").Any(Values[{m.ValueIndex}] >= {m.Threshold})"; + } + + private static string Escape(string s) => + (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/Compartments/QueryParser.cs b/Compartments/QueryParser.cs new file mode 100644 index 0000000..1897521 --- /dev/null +++ b/Compartments/QueryParser.cs @@ -0,0 +1,225 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Stashie.Classes; + +namespace Stashie.Compartments; + +// Reverses QueryCompiler. Recognized atoms become structured leaves; anything else becomes +// a Raw leaf, so Parse(x) is total and Compile(Parse(x)) is always a valid, equivalent query. +public static class QueryParser +{ + public static ConditionNode Parse(string query) + { + if (string.IsNullOrWhiteSpace(query)) + return new ConditionNode { Kind = "And" }; + return ParseExpr(query.Trim()); + } + + // orExpr := andExpr ("||" andExpr)* (|| is lowest precedence, like C#) + private static ConditionNode ParseExpr(string s) + { + var parts = SplitTopLevel(s, "||"); + if (parts.Count == 1) return ParseAnd(parts[0]); + var node = new ConditionNode { Kind = "Or", Children = [] }; + foreach (var p in parts) node.Children.Add(ParseAnd(p)); + return node; + } + + // andExpr := atom ("&&" atom)* + private static ConditionNode ParseAnd(string s) + { + var parts = SplitTopLevel(s, "&&"); + if (parts.Count == 1) return ParseAtom(parts[0]); + var node = new ConditionNode { Kind = "And", Children = [] }; + foreach (var p in parts) node.Children.Add(ParseAtom(p)); + return node; + } + + // atom := "(" orExpr ")" | leaf + private static ConditionNode ParseAtom(string s) + { + s = s.Trim(); + var inner = StripOuterParens(s); + return inner != null ? ParseExpr(inner) : ParseLeaf(s); + } + + // Split on the 2-char operator at paren depth 0, ignoring matches inside double-quoted + // strings (\" and \\ escapes respected). Returns trimmed parts (>=1). + private static List SplitTopLevel(string s, string op) + { + var parts = new List(); + int depth = 0, start = 0; + bool inStr = false; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (inStr) + { + if (c == '\\') { i++; continue; } + if (c == '"') inStr = false; + continue; + } + if (c == '"') { inStr = true; continue; } + if (c == '(') { depth++; continue; } + if (c == ')') { depth--; continue; } + if (depth == 0 && c == op[0] && i + 1 < s.Length && s[i + 1] == op[1]) + { + parts.Add(s.Substring(start, i - start).Trim()); + i++; + start = i + 1; + } + } + parts.Add(s.Substring(start).Trim()); + return parts; + } + + // If s is wrapped by exactly one balanced paren pair, return the inside; else null. + private static string StripOuterParens(string s) + { + if (s.Length < 2 || s[0] != '(' || s[^1] != ')') return null; + int depth = 0; + bool inStr = false; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (inStr) + { + if (c == '\\') { i++; continue; } + if (c == '"') inStr = false; + continue; + } + if (c == '"') { inStr = true; continue; } + if (c == '(') depth++; + else if (c == ')') + { + depth--; + if (depth == 0 && i != s.Length - 1) return null; + } + } + return s.Substring(1, s.Length - 2).Trim(); + } + + private static readonly Regex RxStat = new(@"^ItemStats\.GetValueOrDefault\(GameStat\.([A-Za-z0-9_]+)\)\s*>=\s*(-?\d+)$"); + private static readonly Regex RxModVal = new(@"^FindMods\(""((?:[^""\\]|\\.)*)""\)\.Any\(Values\[(\d+)\]\s*>=\s*(-?\d+)\)$"); + private static readonly Regex RxModHas = new(@"^FindMods\(""((?:[^""\\]|\\.)*)""\)\.Any\(\)$"); + private static readonly Regex RxContains = new(@"^([A-Za-z0-9_.]+)\.Contains\(""((?:[^""\\]|\\.)*)""\)$"); + private static readonly Regex RxNeg = new(@"^!\s*([A-Za-z0-9_.]+)$"); + private static readonly Regex RxBare = new(@"^([A-Za-z0-9_.]+)$"); + private static readonly Regex RxBinary = new(@"^([A-Za-z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$"); + + private static ConditionNode ParseLeaf(string s) + { + s = s.Trim(); + ConditionNode n; + if ((n = TryStat(s)) != null) return n; + if ((n = TryModVal(s)) != null) return n; + if ((n = TryModHas(s)) != null) return n; + if ((n = TryContains(s)) != null) return n; + if ((n = TryBool(s)) != null) return n; + if ((n = TryBinary(s)) != null) return n; + return new ConditionNode { Kind = "Leaf", Raw = s }; + } + + private static ConditionNode TryStat(string s) + { + var m = RxStat.Match(s); + if (!m.Success) return null; + return new ConditionNode { Kind = "Leaf", Mod = new ModRef { + MatchByStat = true, Mode = "Value", + GameStats = [m.Groups[1].Value], + Threshold = int.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture) } }; + } + + private static ConditionNode TryModVal(string s) + { + var m = RxModVal.Match(s); + if (!m.Success) return null; + return new ConditionNode { Kind = "Leaf", Mod = new ModRef { + Mode = "Value", MatchToken = Unescape(m.Groups[1].Value), + ValueIndex = int.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture), + Threshold = int.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture) } }; + } + + private static ConditionNode TryModHas(string s) + { + var m = RxModHas.Match(s); + if (!m.Success) return null; + return new ConditionNode { Kind = "Leaf", Mod = new ModRef { + Mode = "Has", MatchToken = Unescape(m.Groups[1].Value) } }; + } + + private static ConditionNode TryContains(string s) + { + var m = RxContains.Match(s); + if (!m.Success) return null; + var def = AttributeCatalog.FindByDsl(m.Groups[1].Value); + if (def == null || def.Kind != AttrKind.String) return null; + return new ConditionNode { Kind = "Leaf", Attribute = def.Key, Operator = "contains", + Value = Unescape(m.Groups[2].Value) }; + } + + private static ConditionNode TryBool(string s) + { + var neg = RxNeg.Match(s); + if (neg.Success) + { + var d = AttributeCatalog.FindByDsl(neg.Groups[1].Value); + if (d != null && d.Kind == AttrKind.Bool) + return new ConditionNode { Kind = "Leaf", Attribute = d.Key, Operator = "notHas" }; + return null; + } + var bare = RxBare.Match(s); + if (bare.Success) + { + var d = AttributeCatalog.FindByDsl(bare.Groups[1].Value); + if (d != null && d.Kind == AttrKind.Bool) + return new ConditionNode { Kind = "Leaf", Attribute = d.Key, Operator = "has" }; + } + return null; + } + + private static ConditionNode TryBinary(string s) + { + var m = RxBinary.Match(s); + if (!m.Success) return null; + var def = AttributeCatalog.FindByDsl(m.Groups[1].Value); + if (def == null) return null; + var op = m.Groups[2].Value; + var rhs = m.Groups[3].Value.Trim(); + + switch (def.Kind) + { + case AttrKind.String: + if ((op == "==" || op == "!=") && rhs.Length >= 2 && rhs[0] == '"' && rhs[^1] == '"') + return new ConditionNode { Kind = "Leaf", Attribute = def.Key, Operator = op, + Value = Unescape(rhs.Substring(1, rhs.Length - 2)) }; + return null; + case AttrKind.Enum: + var em = Regex.Match(rhs, "^" + Regex.Escape(def.EnumType) + @"\.([A-Za-z0-9_]+)$"); + if (em.Success) + return new ConditionNode { Kind = "Leaf", Attribute = def.Key, Operator = op, + Value = em.Groups[1].Value }; + return null; + case AttrKind.Number: + if (Regex.IsMatch(rhs, @"^-?\d+$")) + return new ConditionNode { Kind = "Leaf", Attribute = def.Key, Operator = op, Value = rhs }; + return null; + default: + return null; + } + } + + // Reverse of QueryCompiler.Escape: turn \" -> " and \\ -> \ (consume the backslash). + private static string Unescape(string s) + { + var sb = new StringBuilder(s.Length); + for (int i = 0; i < s.Length; i++) + { + if (s[i] == '\\' && i + 1 < s.Length) { sb.Append(s[++i]); continue; } + sb.Append(s[i]); + } + return sb.ToString(); + } +} diff --git a/Compartments/StashieEditorHandler.cs b/Compartments/StashieEditorHandler.cs index df60657..a90552f 100644 --- a/Compartments/StashieEditorHandler.cs +++ b/Compartments/StashieEditorHandler.cs @@ -20,6 +20,13 @@ public class StashieEditorHandler public static string FileSaveName = ""; public static string SelectedFileName = ""; + // Auto-save (debounced) of the in-memory editor model to the current filter file. + public static bool AutoSave = true; + private static string _lastSavedJson; + private static string _pendingJson; + private static DateTime _pendingSince; + private static string _autoSaveStatus = ""; + public static List _files = []; public static FilterEditor.Filter condEditValue = new(); public static FilterEditor.Filter tempCondValue = new(); @@ -33,6 +40,18 @@ public static void ConverterMenu() ImGui.Spacing(); + if (ImGui.Button("Save Filter to File")) + SaveCurrentFilter(); + ImGui.SameLine(); + ImGui.Checkbox("Auto-save on edit", ref AutoSave); + ImGui.SameLine(); + var target = !string.IsNullOrEmpty(FileSaveName) ? FileSaveName : SelectedFileName; + ImGui.TextDisabled(string.IsNullOrEmpty(target) + ? "(no file selected - set a name in Load / Save)" + : $"-> {target}.json {_autoSaveStatus}"); + + ImGui.Spacing(); + if (!ImGui.Button("\nConvert Old .ifl To New .json\nOld files will not be altered.\n ")) return; @@ -60,177 +79,72 @@ public static void ConverterMenu() Main.LogError($"Failed to load file, is it possible its not an older style?\n\t{file}", 15); } - public static void DrawEditorMenu() + // Writes the in-memory editor model to the current filter file. If that file is the one the + // stashing engine is using, reload it so edits take effect without a manual reload. + public static void SaveCurrentFilter() { - if (Main.Settings.CurrentFilterOptions.ParentMenu == null) - return; - - var tempFilters = new List(Main.Settings.CurrentFilterOptions.ParentMenu); - - if (!ImGui.CollapsingHeader("Filters", ImGuiTreeNodeFlags.DefaultOpen)) - return; - - #region Parent - - ImGui.Indent(); - - ImGui.InputTextWithHint("Filter Groups", "Group...", ref _editorGroupFilter, 100); - ImGui.InputTextWithHint("Filter Queries", "Query...", ref _editorQueryFilter, 100); - ImGui.InputTextWithHint("Filter Query Content", "Query Content...", ref _editorQueryContentFilter, 100); - - for (var parentIndex = 0; parentIndex < tempFilters.Count; parentIndex++) + var name = !string.IsNullOrEmpty(FileSaveName) ? FileSaveName : SelectedFileName; + if (string.IsNullOrEmpty(name)) { - ImGui.PushID(parentIndex); - - var currentParent = tempFilters[parentIndex]; - if (!currentParent.MenuName.Contains(_editorGroupFilter, StringComparison.InvariantCultureIgnoreCase)) - continue; - - if (currentParent.Filters.All(x => - !x.FilterName.Contains(_editorQueryFilter, StringComparison.InvariantCultureIgnoreCase))) - continue; - - if (currentParent.Filters.All(x => - !x.RawQuery.Contains(_editorQueryContentFilter, StringComparison.InvariantCultureIgnoreCase))) - continue; - - ImGui.BeginChild("parentFilterGroup", Vector2N.Zero, ImGuiChildFlags.Border | ImGuiChildFlags.AutoResizeY); - - if (ImGui.ArrowButton("ArrowButtonUp", ImGuiDir.Up)) - if (parentIndex > 0) - { - ResetEditingIdentifiers(); - (tempFilters[parentIndex - 1], tempFilters[parentIndex]) = - (tempFilters[parentIndex], tempFilters[parentIndex - 1]); - continue; - } - - #region Parents Filters - - ImGui.Indent(); - ImGui.InputTextWithHint("Group Name", "\"Heist Items\" etc..", ref tempFilters[parentIndex].MenuName, 200); - ImGui.BeginChild("parentFilterGroup", Vector2N.Zero, ImGuiChildFlags.Border | ImGuiChildFlags.AutoResizeY); - - #region Filter Query - - for (var filterIndex = 0; filterIndex < tempFilters[parentIndex].Filters.Count; filterIndex++) - { - ImGui.PushID(filterIndex); - var currentFilter = currentParent.Filters[filterIndex]; - if (!currentFilter.FilterName.Contains(_editorQueryFilter, StringComparison.InvariantCultureIgnoreCase)) - continue; - - if (!currentFilter.RawQuery.Contains(_editorQueryContentFilter, - StringComparison.InvariantCultureIgnoreCase)) - continue; - - ImGui.InputTextWithHint("", "\"Heist Items\" etc..", - ref tempFilters[parentIndex].Filters[filterIndex].FilterName, 200); - - ImGui.SameLine(); - CheckboxWithTooltip("Shifting", ref currentFilter.Shifting, "Holds Shift to bypass Tab Affinity."); - ImGui.SameLine(); - CheckboxWithTooltip("Affinity", ref currentFilter.Affinity, - "Assumes Affinity is set and won't change to selected stash tab\nwhen stashing items."); - - #region Edit Button NEW - - ImGui.SameLine(); - var isEditing = IsCurrentEditorContext(parentIndex, filterIndex); - - if (isEditing) BeginFilterEditWindow(parentIndex, filterIndex, tempFilters); - - var editString = isEditing ? "Editing" : "Edit"; - if (ImGui.Button($"{editString}")) - { - if (isEditing) - { - ResetEditingIdentifiers(); - } - else - { - condEditValue = new FilterEditor.Filter - { - FilterName = currentFilter.FilterName, Affinity = currentFilter.Affinity, - RawQuery = currentFilter.RawQuery, Shifting = currentFilter.Shifting - }; - - tempCondValue = new FilterEditor.Filter - { - FilterName = currentFilter.FilterName, Affinity = currentFilter.Affinity, - RawQuery = currentFilter.RawQuery, Shifting = currentFilter.Shifting - }; - - Editor = new EditorRecord(parentIndex, filterIndex); - } - } - - #endregion + Main.LogError("[Stashie] No filter file selected - set a name in Load / Save first.", 5); + return; + } - ImGui.SameLine(); - if (ImGui.Button("Delete")) - { - ResetEditingIdentifiers(); - tempFilters[parentIndex].Filters.RemoveAt(filterIndex); - } + FileManager.SaveToFile(Main.Settings.CurrentFilterOptions, name); + try { _lastSavedJson = Newtonsoft.Json.JsonConvert.SerializeObject(Main.Settings.CurrentFilterOptions); } + catch { /* ignore */ } + _pendingJson = null; + _autoSaveStatus = $"saved {DateTime.Now:HH:mm:ss}"; - ImGui.PopID(); - } + if (name == Main.Settings.FilterFile.Value) + FilterManager.LoadCustomFilters(); + } - if (ImGui.Button("[=] Add New Filter")) - { - ResetEditingIdentifiers(); - tempFilters[parentIndex].Filters.Add(new FilterEditor.Filter - { FilterName = "", RawQuery = "", Affinity = false, Shifting = false }); - } + // Called every frame the Filter Editor tab is open. Writes the editor model to the current + // file ~1s after edits settle, then reloads it into the running stashing engine when it's the + // active filter file — so edits take effect live without a manual Save/reload. The 1s debounce + // keeps the reload from firing on every keystroke. + public static void AutoSaveTick() + { + if (!AutoSave) return; - #endregion + var name = !string.IsNullOrEmpty(FileSaveName) ? FileSaveName : SelectedFileName; + if (string.IsNullOrEmpty(name) || Main.Settings.CurrentFilterOptions?.ParentMenu == null) + return; - ImGui.EndChild(); - ImGui.Unindent(); + string json; + try { json = Newtonsoft.Json.JsonConvert.SerializeObject(Main.Settings.CurrentFilterOptions); } + catch { return; } - if (ImGui.ArrowButton("", ImGuiDir.Down)) - if (parentIndex < tempFilters.Count - 1) - { - ResetEditingIdentifiers(); - (tempFilters[parentIndex + 1], tempFilters[parentIndex]) = - (tempFilters[parentIndex], tempFilters[parentIndex + 1]); - continue; - } + if (_lastSavedJson == null) { _lastSavedJson = json; return; } // baseline; don't save on open + if (json == _lastSavedJson) { _pendingJson = null; return; } // nothing changed - ImGui.SameLine(); + // Debounce: wait for edits to settle (~1s of no further changes) before writing. + if (json != _pendingJson) { _pendingJson = json; _pendingSince = DateTime.Now; return; } + if ((DateTime.Now - _pendingSince).TotalSeconds < 1.0) return; - if (ImGui.Button("[X] Delete Group")) - { - tempFilters.RemoveAt(parentIndex); - ResetEditingIdentifiers(); - } + FileManager.SaveToFile(Main.Settings.CurrentFilterOptions, name); + _lastSavedJson = json; + _pendingJson = null; - #endregion + // Reload into the running engine when the saved file is the active filter file, mirroring + // the manual Save button so auto-saved edits apply live. + var reloaded = name == Main.Settings.FilterFile.Value; + if (reloaded) FilterManager.LoadCustomFilters(); + _autoSaveStatus = $"{(reloaded ? "auto-saved + reloaded" : "auto-saved")} {DateTime.Now:HH:mm:ss}"; + } - ImGui.Unindent(); - ImGui.EndChild(); - ImGui.Spacing(); - ImGui.PopID(); - } + public static void DrawEditorMenu() + { + if (Main.Settings.CurrentFilterOptions?.ParentMenu == null) + return; - ImGui.Unindent(); - if (ImGui.Button("[=] Add New Group")) - { - ResetEditingIdentifiers(); - tempFilters.Add(new FilterEditor.ParentMenu - { - MenuName = "", - Filters = - [ - new FilterEditor.Filter { FilterName = "", RawQuery = "", Affinity = false, Shifting = false } - ] - }); - } + AutoSaveTick(); - #endregion + if (!ImGui.CollapsingHeader("Filters", ImGuiTreeNodeFlags.DefaultOpen)) + return; - Main.Settings.CurrentFilterOptions.ParentMenu = tempFilters; + FilterBuilderEditor.Draw(); } private static void BeginFilterEditWindow(int parentIndex, int filterIndex, diff --git a/Compartments/StashieSettingsHandler.cs b/Compartments/StashieSettingsHandler.cs index 7a192e9..4aa913e 100644 --- a/Compartments/StashieSettingsHandler.cs +++ b/Compartments/StashieSettingsHandler.cs @@ -61,8 +61,12 @@ public static void GenerateTabMenu() ImGui.TextColored(new Vector4N(0f, 1f, 0.022f, 1f), parent.ParentMenuName); foreach (var filter in parent.Filters) - if (Main.Settings.CustomFilterOptions.TryGetValue(parent.ParentMenuName + filter.FilterName, - out var indexNode)) + { + // Render an assignment row for every loaded filter using the StashIndexNode the + // loader already resolved (FilterTabById for editor-created filters, CustomFilterOptions + // for legacy ones). Gating on CustomFilterOptions alone hid editor-created filters. + var indexNode = filter.StashIndexNode; + if (indexNode != null) { var strId = $"{filter.FilterName}##{parent.ParentMenuName + filter.FilterName}"; @@ -131,10 +135,7 @@ public static void GenerateTabMenu() ImGui.EndPopup(); } - else - { - indexNode = new ListIndexNode { Value = "Ignore", Index = -1 }; - } + } }; } diff --git a/Compartments/TabAssignmentMigration.cs b/Compartments/TabAssignmentMigration.cs new file mode 100644 index 0000000..d2b5153 --- /dev/null +++ b/Compartments/TabAssignmentMigration.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using Stashie.Classes; + +namespace Stashie.Compartments; + +public static class TabAssignmentMigration +{ + // Maps a name-keyed assignment dict (key = ParentMenuName + FilterName) to an id-keyed one. + // `clone` lets callers deep-copy the value (ListIndexNode in production; identity in tests). + public static Dictionary Migrate( + FilterEditor.FilterParent filters, + Dictionary byName, + Func clone) + { + var byId = new Dictionary(); + if (filters?.ParentMenu == null) return byId; + + foreach (var group in filters.ParentMenu) + foreach (var filter in group.Filters) + { + if (string.IsNullOrEmpty(filter.Id)) continue; + var nameKey = group.MenuName + filter.FilterName; + if (byName.TryGetValue(nameKey, out var value)) + byId[filter.Id] = clone(value); + } + + return byId; + } +} diff --git a/Stashie.cs b/Stashie.cs index d590b83..924906a 100644 --- a/Stashie.cs +++ b/Stashie.cs @@ -56,9 +56,11 @@ public override bool Initialise() Input.RegisterKey(Settings.DropHotkey); Input.RegisterKey(Settings.DynamicIgnoreHotkey.Value); + Input.RegisterKey(Settings.ScannerGrabHotkey.Value); Settings.DropHotkey.OnValueChanged += () => { Input.RegisterKey(Settings.DropHotkey); }; Settings.DynamicIgnoreHotkey.OnValueChanged += () => { Input.RegisterKey(Settings.DynamicIgnoreHotkey.Value); }; + Settings.ScannerGrabHotkey.OnValueChanged += () => Input.RegisterKey(Settings.ScannerGrabHotkey.Value); Settings.FilterFile.OnValueSelected = _ => FilterManager.LoadCustomFilters(); // Dynamic-ignore lock icon: register the texture once. If it can't be found, DrawIcons @@ -151,6 +153,7 @@ public override void AreaChange(AreaInstance area) public override void Tick() { DynamicIgnore.HandleHotkey(); + ItemScanner.HandleGrabHotkey(); if (!StashingRequirementsMet()) { diff --git a/StashieSettings.cs b/StashieSettings.cs index 01b97e9..47cced9 100644 --- a/StashieSettings.cs +++ b/StashieSettings.cs @@ -14,6 +14,9 @@ public class StashieSettings : ISettings public List AllStashNames = []; public Dictionary CustomFilterOptions = []; + // Stash-tab assignment keyed by stable Filter.Id (migrated from CustomFilterOptions). + public Dictionary FilterTabById = []; + [Menu("Filter File")] public ListNode FilterFile { get; set; } = new(); [Menu("Stash Hotkey")] public HotkeyNode DropHotkey { get; set; } = Keys.F3; @@ -68,6 +71,10 @@ public class StashieSettings : ISettings // the plugin's settings JSON (like IgnoredCells). Self-pruned at stash time. See DynamicIgnore. public List LockedItemFingerprints { get; set; } = new(); + [Menu("Scanner: Grab Hovered Item Hotkey", + "In the Filter Editor, hover an item (inventory/stash/ground) and press this to add it to the discovery pool.")] + public HotkeyNode ScannerGrabHotkey { get; set; } = Keys.V; + public int[,] IgnoredCells { get; set; } = new int[5, 12]; public int[,] IgnoredExpandedCells { get; set; } = new int[5, 4]; diff --git a/Ui/Controls.cs b/Ui/Controls.cs new file mode 100644 index 0000000..bc719c6 --- /dev/null +++ b/Ui/Controls.cs @@ -0,0 +1,42 @@ +using System.Drawing; +using ExileCore2.Shared.Helpers; // ToImguiVec4 +using ImGuiNET; +using Stashie.Classes; // AttrKind +using Stashie.Compartments; // ChipCategory +using Vector4N = System.Numerics.Vector4; + +namespace Stashie.Ui; + +public static class Controls +{ + public static readonly Vector4N Accent = new(0.88f, 0.56f, 0.24f, 1f); // active-group border + public static readonly Vector4N Warn = new(0.95f, 0.60f, 0.25f, 1f); // raw-leaf tint + public static readonly Vector4N Muted = new(0.55f, 0.53f, 0.48f, 1f); + + // null = neutral, true = ok/green, false = error/red. + public static void StatusDot(bool? ok) + { + var col = ok switch { true => Color.LimeGreen, false => Color.IndianRed, _ => Color.Gray }; + var s = ok switch { true => "(*)", false => "(x)", _ => "( )" }; + ImGui.TextColored(col.ToImguiVec4(), s); + } + + public static Vector4N CategoryColor(ChipCategory c) => (c switch + { + ChipCategory.Class => Color.SkyBlue, + ChipCategory.Base => Color.Goldenrod, + ChipCategory.Flag => Color.MediumAquamarine, + ChipCategory.Socket => Color.MediumPurple, + ChipCategory.Mod => Color.YellowGreen, + _ => Color.Gainsboro, + }).ToImguiVec4(); + + public static Vector4N AttrColor(AttrKind k) => (k switch + { + AttrKind.String => Color.Goldenrod, + AttrKind.Number => Color.SkyBlue, + AttrKind.Enum => Color.MediumPurple, + AttrKind.Bool => Color.LightSalmon, + _ => Color.Gainsboro, + }).ToImguiVec4(); +} diff --git a/Ui/StashieTheme.cs b/Ui/StashieTheme.cs new file mode 100644 index 0000000..1b9d899 --- /dev/null +++ b/Ui/StashieTheme.cs @@ -0,0 +1,57 @@ +using System.Numerics; +using ImGuiNET; + +namespace Stashie.Ui; + +// Scoped ImGui style for the filter editor. Push() at the start of Draw, Pop() in finally, +// so the warm-orange theme applies only to our content and never leaks into other plugins. +public static class StashieTheme +{ + private static int _colors; + private static int _vars; + + public static void Push() + { + _colors = 0; + _vars = 0; + + Color(ImGuiCol.Header, 0.66f, 0.33f, 0.09f, 0.85f); + Color(ImGuiCol.HeaderHovered, 0.80f, 0.42f, 0.12f, 1f); + Color(ImGuiCol.HeaderActive, 0.80f, 0.42f, 0.12f, 1f); + + Color(ImGuiCol.Button, 0.34f, 0.20f, 0.09f, 1f); + Color(ImGuiCol.ButtonHovered, 0.66f, 0.33f, 0.09f, 1f); + Color(ImGuiCol.ButtonActive, 0.80f, 0.42f, 0.12f, 1f); + + Color(ImGuiCol.FrameBg, 0.15f, 0.13f, 0.11f, 1f); + Color(ImGuiCol.FrameBgHovered, 0.24f, 0.20f, 0.15f, 1f); + Color(ImGuiCol.FrameBgActive, 0.30f, 0.24f, 0.17f, 1f); + Color(ImGuiCol.SliderGrab, 0.80f, 0.42f, 0.12f, 1f); + Color(ImGuiCol.SliderGrabActive, 0.92f, 0.52f, 0.16f, 1f); + Color(ImGuiCol.CheckMark, 0.92f, 0.52f, 0.16f, 1f); + + Var(ImGuiStyleVar.FrameRounding, 4f); + Var(ImGuiStyleVar.GrabRounding, 4f); + Var(ImGuiStyleVar.FrameBorderSize, 1f); + Var(ImGuiStyleVar.FramePadding, new Vector2(6, 4)); + Var(ImGuiStyleVar.ItemSpacing, new Vector2(8, 6)); + Var(ImGuiStyleVar.IndentSpacing, 14f); + } + + public static void Pop() + { + ImGui.PopStyleVar(_vars); + ImGui.PopStyleColor(_colors); + _vars = 0; + _colors = 0; + } + + private static void Color(ImGuiCol target, float r, float g, float b, float a) + { + ImGui.PushStyleColor(target, new Vector4(r, g, b, a)); + _colors++; + } + + private static void Var(ImGuiStyleVar target, float value) { ImGui.PushStyleVar(target, value); _vars++; } + private static void Var(ImGuiStyleVar target, Vector2 value) { ImGui.PushStyleVar(target, value); _vars++; } +} From 3e57652f0c47b9eecd44c40d383d39ff1db2ac37 Mon Sep 17 00:00:00 2001 From: ansidian Date: Tue, 16 Jun 2026 15:43:54 -0700 Subject: [PATCH 3/5] fix(filter): full config reload on filter auto-save Auto-save (and the manual "Save Filter to File") only reloaded the stashing engine's rules via LoadCustomFilters, not the stash-tab assignment menu (GenerateTabMenu). New or renamed filters therefore did not appear in the tab list until the manual "Reload config" button was clicked. Extract the button's two steps into StashieSettingsHandler.ReloadConfig (LoadCustomFilters + GenerateTabMenu, guarded against a missing filter file) and route the manual button, SaveCurrentFilter, and AutoSaveTick through it, so auto-saved edits apply live without a manual reload. --- Compartments/StashieEditorHandler.cs | 13 ++++++++----- Compartments/StashieSettingsHandler.cs | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Compartments/StashieEditorHandler.cs b/Compartments/StashieEditorHandler.cs index a90552f..a120abc 100644 --- a/Compartments/StashieEditorHandler.cs +++ b/Compartments/StashieEditorHandler.cs @@ -80,7 +80,8 @@ public static void ConverterMenu() } // Writes the in-memory editor model to the current filter file. If that file is the one the - // stashing engine is using, reload it so edits take effect without a manual reload. + // stashing engine is using, run the full config reload (rules + tab-assignment menu) so edits + // take effect without a manual "Reload config". public static void SaveCurrentFilter() { var name = !string.IsNullOrEmpty(FileSaveName) ? FileSaveName : SelectedFileName; @@ -97,7 +98,7 @@ public static void SaveCurrentFilter() _autoSaveStatus = $"saved {DateTime.Now:HH:mm:ss}"; if (name == Main.Settings.FilterFile.Value) - FilterManager.LoadCustomFilters(); + StashieSettingsHandler.ReloadConfig(); } // Called every frame the Filter Editor tab is open. Writes the editor model to the current @@ -127,10 +128,12 @@ public static void AutoSaveTick() _lastSavedJson = json; _pendingJson = null; - // Reload into the running engine when the saved file is the active filter file, mirroring - // the manual Save button so auto-saved edits apply live. + // Full config reload (engine rules + tab-assignment menu) when the saved file is the active + // filter file, mirroring the manual "Reload config" button so auto-saved edits apply live + // without it. GenerateTabMenu alone was missing before, so new filters didn't show up in the + // tab list until a manual reload. var reloaded = name == Main.Settings.FilterFile.Value; - if (reloaded) FilterManager.LoadCustomFilters(); + if (reloaded) StashieSettingsHandler.ReloadConfig(); _autoSaveStatus = $"{(reloaded ? "auto-saved + reloaded" : "auto-saved")} {DateTime.Now:HH:mm:ss}"; } diff --git a/Compartments/StashieSettingsHandler.cs b/Compartments/StashieSettingsHandler.cs index 4aa913e..256ecda 100644 --- a/Compartments/StashieSettingsHandler.cs +++ b/Compartments/StashieSettingsHandler.cs @@ -139,13 +139,23 @@ public static void GenerateTabMenu() }; } + // The full "Reload config" action: reload the active filter file into the stashing engine and + // rebuild the stash-tab assignment menu so added/removed filters appear. Shared by the manual + // "Reload config" button and the editor's save / auto-save paths, so edits apply live without a + // manual reload. GenerateTabMenu iterates Main.currentFilter, so guard against a missing file. + public static void ReloadConfig() + { + FilterManager.LoadCustomFilters(); + if (Main.currentFilter != null) + GenerateTabMenu(); + } + public static void DrawReloadConfigButton() { if (!ImGui.Button("Reload config")) return; - FilterManager.LoadCustomFilters(); - GenerateTabMenu(); + ReloadConfig(); DebugWindow.LogMsg("Reloaded Stashie config", 2, Color.LimeGreen); } From b7dbdd503ea4167493dbb6423d973c87a2a9dc87 Mon Sep 17 00:00:00 2001 From: ansidian Date: Tue, 16 Jun 2026 15:44:07 -0700 Subject: [PATCH 4/5] feat(scanner): surface rich item stats when grabbing items Grabbed items now show a "stats" section in the filter-builder card with one-click ">= current value" conditions: - Scalars: Item Level and Quality (all items), Map Tier (waystones). - Map reward block: Item Rarity, Item Quantity, Pack Size, Monster Rarity, Waystone Drop Chance, read from ItemData.ItemStats with clean tooltip labels. IFL's query language is Dynamic LINQ, so stat conditions compile to the indexer form `ItemStats[GameStat.X] >= N` -- GetValueOrDefault is an unregistered extension and does not resolve. ItemStats is a DefaultDictionary, so the bare indexer is eval-safe on items lacking the key. Only the "...FinalFromMap" reward stats are emitted; every other ItemStats entry is a per-mod stat already shown in the mod list. Also fixes the MapTier attribute DSL (now MapInfo.Tier), which previously failed to compile and was silently pruned. --- Classes/AttributeCatalog.cs | 5 +- Compartments/FilterBuilderEditor.cs | 20 ++++- Compartments/ItemScanner.cs | 135 +++++++++++++++++++++++++++- Compartments/QueryCompiler.cs | 5 +- 4 files changed, 161 insertions(+), 4 deletions(-) diff --git a/Classes/AttributeCatalog.cs b/Classes/AttributeCatalog.cs index 146c12c..2cffdf8 100644 --- a/Classes/AttributeCatalog.cs +++ b/Classes/AttributeCatalog.cs @@ -26,7 +26,10 @@ public static class AttributeCatalog EnumValues = ["Normal","Magic","Rare","Unique"], Operators = ["==","!="] }, new() { Key = "ItemLevel", Dsl = "ItemLevel", Kind = AttrKind.Number, Operators = ["==","!=",">=","<=",">","<"] }, new() { Key = "ItemQuality",Dsl = "ItemQuality",Kind = AttrKind.Number, Operators = ["==",">=","<="] }, - new() { Key = "MapTier", Dsl = "MapTier", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + new() { Key = "Armour", Dsl = "ArmourInfo.Armour", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + new() { Key = "Evasion", Dsl = "ArmourInfo.Evasion", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + new() { Key = "EnergyShield", Dsl = "ArmourInfo.ES", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, + new() { Key = "MapTier", Dsl = "MapInfo.Tier", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, new() { Key = "Sockets", Dsl = "SocketInfo.SocketNumber", Kind = AttrKind.Number, Operators = ["==",">="] }, new() { Key = "Links", Dsl = "SocketInfo.LargestLinkSize", Kind = AttrKind.Number, Operators = ["==",">="] }, new() { Key = "StackSize", Dsl = "StackInfo.Count", Kind = AttrKind.Number, Operators = ["==",">=","<="] }, diff --git a/Compartments/FilterBuilderEditor.cs b/Compartments/FilterBuilderEditor.cs index 29b69e5..ae18650 100644 --- a/Compartments/FilterBuilderEditor.cs +++ b/Compartments/FilterBuilderEditor.cs @@ -393,7 +393,7 @@ private static void DrawGrabbedCard(ItemData item, bool canAdd, out bool remove) if (meta.Count > 0) ImGui.TextDisabled(string.Join(" - ", meta)); // Attribute quick-adds (Class, Base, Rarity, flags, sockets), one row each. - foreach (var c in chips.Where(c => c.Category != ChipCategory.Mod && MatchesSearch(c))) + foreach (var c in chips.Where(c => c.Category is not (ChipCategory.Mod or ChipCategory.Stat) && MatchesSearch(c))) { ImGui.PushID(c.DedupKey); if (AddButton("+", canAdd)) AddChipToSelected(c); @@ -402,6 +402,24 @@ private static void DrawGrabbedCard(ItemData item, bool canAdd, out bool remove) ImGui.PopID(); } + // Rich stats (the tooltip's augmented header block): Item Level, Quality, Map tier/rarity/ + // quantity/pack size, and aggregated GameStats. Each adds a ">= current value" condition. + var stats = chips.Where(c => c.Category == ChipCategory.Stat && MatchesSearch(c)).ToList(); + if (stats.Count > 0) ImGui.TextDisabled("stats"); + foreach (var c in stats) + { + ImGui.PushID(c.DedupKey); + if (AddButton("+", canAdd)) AddChipToSelected(c); + ImGui.SameLine(); + ImGui.TextUnformatted(c.Display); + ImGui.SameLine(); + if (c.Mod is { MatchByStat: true }) + ImGui.TextDisabled($">= {c.Mod.Threshold}"); + else if (!string.IsNullOrEmpty(c.Value)) + ImGui.TextDisabled($"{c.Operator} {c.Value}"); + ImGui.PopID(); + } + var mods = chips.Where(c => c.Category == ChipCategory.Mod && MatchesSearch(c)).ToList(); if (mods.Count > 0) ImGui.TextDisabled("mods"); foreach (var c in mods) diff --git a/Compartments/ItemScanner.cs b/Compartments/ItemScanner.cs index c8d271f..b14d473 100644 --- a/Compartments/ItemScanner.cs +++ b/Compartments/ItemScanner.cs @@ -12,7 +12,7 @@ namespace Stashie.Compartments; -public enum ChipCategory { Class, Base, Rarity, Flag, Socket, Mod } +public enum ChipCategory { Class, Base, Rarity, Stat, Flag, Socket, Mod } public class Chip { @@ -154,6 +154,15 @@ internal static IEnumerable ChipsForItem(ItemData item) Attribute = "Rarity", Operator = "==", Value = rarityName, DedupKey = "Rarity:" + rarityName }; + // Rich header data (the tooltip's "augmented" block): Item Level, Quality, Map tier/rarity/ + // quantity/pack size, and aggregated GameStats (Item Rarity, Monster Rarity, Waystone Drop + // Chance, Revives, ...). Isolated like the other sections so an unreadable item just yields + // fewer chips and self-heals on a later frame. + List stats; + try { stats = StatChips(item).ToList(); } catch { stats = []; } + foreach (var chip in stats) + yield return chip; + List<(string key, string display)> flags; try { flags = BoolFlags(item).ToList(); } catch { flags = []; } foreach (var (key, display) in flags) @@ -248,4 +257,128 @@ private static string[] ResolveGameStats(ItemMod mod) { return System.Array.Empty(); } + + // ---- Rich stat chips (tooltip "augmented" header block) --------------------------------- + + // Whether a stat's ItemStats DSL compiles on the live IFL, cached. Guards against GameStat + // values that have no enum name (ToString() yields a number), which wouldn't compile. + private static readonly Dictionary _statCompileCache = new(); + + // Clean labels for the map reward stats, matching the in-game tooltip's augmented header. Any + // other "...FinalFromMap" stat (future additions) falls back to a cleaned-up name. + private static readonly Dictionary MapRewardLabels = new(StringComparer.Ordinal) + { + ["MapItemDropRarityPctFinalFromMap"] = "Item Rarity", + ["MapItemDropQuantityPctFinalFromMap"] = "Item Quantity", + ["MapPackSizePctFinalFromMap"] = "Pack Size", + ["MapMapItemDropChancePctFinalFromMap"] = "Waystone Drop Chance", + ["MapNumberOfMagicAndRarePacksPctFinalAndRareMonsterModifiersChancePctFinalFromMap"] = "Monster Rarity", + }; + + // The header data the tooltip shows above the mod list: scalars (Item Level, Quality, Map Tier) + // plus the map reward block (Item Rarity, Pack Size, Monster Rarity, Waystone Drop Chance). Each + // emits a chip that adds a ">= current value" condition. + internal static IEnumerable StatChips(ItemData item) + { + var ilvl = TryRead(() => item.ItemLevel, 0); + if (ilvl > 0 && AttributeCatalog.Find("ItemLevel") != null) + yield return ScalarChip("ItemLevel", "Item Level", ilvl); + + var quality = TryRead(() => item.ItemQuality, 0); + if (quality > 0 && AttributeCatalog.Find("ItemQuality") != null) + yield return ScalarChip("ItemQuality", "Quality", quality); + + // MapInfo.Tier is the one MapInfo field IFL actually fills. Rarity/Quantity/PackSize read 0 + // (IFL reads the non-"FinalFromMap" stat keys, which waystones don't carry), so the real + // reward values come through the aggregated ItemStats below instead. + var map = TryRead(() => item.MapInfo); + if (map != null && TryRead(() => map.IsMap, false) && map.Tier > 0 && + AttributeCatalog.Find("MapTier") != null) + yield return ScalarChip("MapTier", "Map Tier", map.Tier); + + var arm = TryRead(() => item.ArmourInfo); + if (arm != null) + { + if (arm.Armour > 0 && AttributeCatalog.Find("Armour") != null) + yield return ScalarChip("Armour", "Base Armour", arm.Armour); + if (arm.Evasion > 0 && AttributeCatalog.Find("Evasion") != null) + yield return ScalarChip("Evasion", "Base Evasion", arm.Evasion); + if (arm.ES > 0 && AttributeCatalog.Find("EnergyShield") != null) + yield return ScalarChip("EnergyShield", "Base Energy Shield", arm.ES); + } + + IReadOnlyDictionary agg = null; + try { agg = item.ItemStats; } catch { agg = null; } + if (agg == null) yield break; + + foreach (var kv in agg) + { + var name = kv.Key.ToString(); + if (name.IndexOf("FinalFromMap", StringComparison.Ordinal) < 0) continue; // reward block only + if (!StatCompiles(name)) continue; // unnamed GameStat guard + + var label = MapRewardLabels.TryGetValue(name, out var l) ? l : CleanHumanize(name); + + yield return new Chip + { + Category = ChipCategory.Stat, + Display = label, + DedupKey = "Stat:" + name, + Mod = new ModRef + { + Display = label, + MatchByStat = true, + GameStats = [name], + Mode = "Value", + Threshold = kv.Value, + } + }; + } + } + + // Scalar attribute chip (Item Level, Quality, Map Tier). Display is the bare label; the card + // appends ">= value" from Operator/Value. Compiles via AttributeCatalog (caller checks the key). + private static Chip ScalarChip(string attrKey, string display, int value) => new() + { + Category = ChipCategory.Stat, + Display = display, + Attribute = attrKey, + Operator = ">=", + Value = value.ToString(), + DedupKey = "Stat:" + attrKey, + }; + + // Fallback label for a "...FinalFromMap" reward stat not in MapRewardLabels: drop the noisy + // suffixes/prefix, then split, e.g. "MapItemDropRarityPctFinalFromMap" -> "Item Drop Rarity". + private static string CleanHumanize(string enumName) + { + var s = (enumName ?? "").Replace("FinalFromMap", "").Replace("Pct", ""); + if (s.StartsWith("Map", StringComparison.Ordinal)) s = s.Substring(3); + return Humanize(s); + } + + // Splits a PascalCase identifier into words, e.g. "ItemDropRarity" -> "Item Drop Rarity". + private static string Humanize(string enumName) + { + if (string.IsNullOrEmpty(enumName)) return enumName; + var sb = new System.Text.StringBuilder(enumName.Length + 8); + for (var i = 0; i < enumName.Length; i++) + { + var c = enumName[i]; + if (i > 0 && char.IsUpper(c) && !char.IsUpper(enumName[i - 1])) sb.Append(' '); + sb.Append(c); + } + return sb.ToString(); + } + + // True if `ItemStats[GameStat.X] >= 0` compiles on the live IFL — i.e. X is a named GameStat + // member. IFL uses Dynamic LINQ, so the indexer (not the GetValueOrDefault extension) is the + // form that resolves. Cached per stat name. + private static bool StatCompiles(string statName) + { + if (_statCompileCache.TryGetValue(statName, out var ok)) return ok; + ok = QueryCompiler.Validate($"ItemStats[GameStat.{statName}] >= 0").ok; + _statCompileCache[statName] = ok; + return ok; + } } diff --git a/Compartments/QueryCompiler.cs b/Compartments/QueryCompiler.cs index 84f1fb4..3c6d944 100644 --- a/Compartments/QueryCompiler.cs +++ b/Compartments/QueryCompiler.cs @@ -77,7 +77,10 @@ private static string CompileMod(ModRef m) if (m.MatchByStat) { var stat = m.GameStats != null && m.GameStats.Length > 0 ? m.GameStats[0] : ""; - return $"ItemStats.GetValueOrDefault(GameStat.{stat}) >= {m.Threshold}"; + // IFL's query language is Dynamic LINQ, not C#: GetValueOrDefault (a System.Collections + // extension) isn't registered and won't resolve, but the indexer does. ItemStats is IFL's + // DefaultDictionary (missing keys read 0), so the bare indexer is also eval-safe. + return $"ItemStats[GameStat.{stat}] >= {m.Threshold}"; } if (m.Mode == "Has") From 575f8347ce50de9d4955bb2a88629657667c82ca Mon Sep 17 00:00:00 2001 From: ansidian Date: Tue, 16 Jun 2026 21:13:24 -0700 Subject: [PATCH 5/5] fix(scanner): stabilize grabbed snapshots, stop mods being dropped, add Unidentified flag Grabbed items held a live ItemData and re-read it every frame, so picking an item up right after grabbing reused its memory slot and turned mod values into garbage. Snapshot the chips into plain values, refresh only while the source entity's identity holds, and merge monotonically so a teardown read can never downgrade a good value. Read mods directly from the live Mods component and isolate each mod read: a single mod whose .Translation throws no longer aborts the enumeration and drops every other mod on the item. Add an "Unidentified" metadata flag (IsIdentified + notHas) for unidentified Magic/Rare/Unique items, alongside the existing "Identified". --- Compartments/FilterBuilderEditor.cs | 13 +- Compartments/ItemScanner.cs | 339 +++++++++++++++++++++++++--- Stashie.cs | 1 + 3 files changed, 311 insertions(+), 42 deletions(-) diff --git a/Compartments/FilterBuilderEditor.cs b/Compartments/FilterBuilderEditor.cs index ae18650..2721d87 100644 --- a/Compartments/FilterBuilderEditor.cs +++ b/Compartments/FilterBuilderEditor.cs @@ -353,16 +353,15 @@ private static void DrawScanner() } // One bordered card per grabbed item: header + attribute quick-adds + mod rows. - private static void DrawGrabbedCard(ItemData item, bool canAdd, out bool remove) + private static void DrawGrabbedCard(GrabbedItem item, bool canAdd, out bool remove) { remove = false; - // ChipsForItem reads each section of the live ItemData defensively and won't throw; an - // item the game hasn't streamed into memory yet simply yields fewer (or no) chips and - // fills in on a later frame as the live data resolves (e.g. after pickup). - List chips; - try { chips = ItemScanner.ChipsForItem(item).ToList(); } - catch { chips = []; } + // Render the FROZEN snapshot captured at grab time — never re-read the live entity here. + // Re-reading a picked-up item's reused memory slot was the source of the flickering huge + // values and the "" scramble; ItemScanner.RefreshPool keeps this list current only + // while the source entity is still valid, then freezes it for good. + var chips = item.Chips ?? []; var className = chips.FirstOrDefault(c => c.Category == ChipCategory.Class)?.Display; var baseName = chips.FirstOrDefault(c => c.Category == ChipCategory.Base)?.Display; diff --git a/Compartments/ItemScanner.cs b/Compartments/ItemScanner.cs index b14d473..08373de 100644 --- a/Compartments/ItemScanner.cs +++ b/Compartments/ItemScanner.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using ExileCore2; // Input using ExileCore2.PoEMemory; // Element +using ExileCore2.PoEMemory.Components; // Mods using ExileCore2.PoEMemory.Elements.InventoryElements; // NormalInventoryItem using ExileCore2.PoEMemory.MemoryObjects; // Entity, ItemMod using ExileCore2.Shared.Enums; // ItemRarity @@ -25,17 +27,36 @@ public class Chip public string DedupKey; // category + identity } +// A grabbed item is a value snapshot of plain Chip objects, NOT a live memory handle. The card +// renders Chips directly. While the source entity is still the same live item we re-read it fresh +// each tick and MERGE the result in monotonically (RefreshPool/Merge): mods that stream in after the +// grab get added, real rolls upgrade placeholders, but a good value is never downgraded and a mod is +// never dropped — so a teardown read during pickup can't reset values to 0 / garbage. Once the read +// settles (or the source entity is picked up / freed / its slot reused) the snapshot is frozen. +public sealed class GrabbedItem +{ + public List Chips = []; // rendered by the card; merged across captures, never downgraded + public bool Frozen; // true => snapshot is final, stop touching live memory + + internal Entity Source; // live source entity, re-wrapped into a FRESH ItemData each capture + internal long SrcAddress; // source-entity identity, used to detect slot reuse + internal uint SrcId; + internal string SrcPath; + internal int StableTicks; // ticks since the snapshot last improved; drives the settle-freeze + internal int AgeTicks; // ticks since grab; drives the absolute CPU backstop +} + public static class ItemScanner { - // Session-only pool of grabbed items (kept as ItemData snapshots). - public static readonly List Pool = []; + // Session-only pool of grabbed items (frozen Chip snapshots, not live memory handles). + public static readonly List Pool = []; public static bool _grabKeyWasDown; public static void ClearPool() => Pool.Clear(); // Edge-detected grab; call from Tick. Reads the hovered inventory item (reusing - // DynamicIgnore) and adds an ItemData snapshot to the pool. + // DynamicIgnore) and adds a frozen-on-settle chip snapshot to the pool. public static void HandleGrabHotkey() { var key = Main.Settings.ScannerGrabHotkey.Value; @@ -66,7 +87,16 @@ private static void TryPool(Entity ent) { var data = new ItemData(ent, Main.GameController); if (string.IsNullOrEmpty(data.Path) || !data.Path.Contains("Metadata/Items")) return; - Pool.Add(data); + + var g = new GrabbedItem + { + Source = ent, + SrcAddress = ent.Address, + SrcId = ent.Id, + SrcPath = ent.Path, + }; + Capture(g); // first snapshot (entity is valid now) + Pool.Add(g); } catch { @@ -74,6 +104,194 @@ private static void TryPool(Entity ent) } } + // ---- Pool stabilization: merge fresh reads monotonically; freeze on settle or pickup ----------- + + // Freeze a settled snapshot this many ticks after it last improved — but only once it actually has + // mods, so an item whose Mods component streams in late still gets them (it keeps re-reading until + // they arrive). Pickup freezes immediately via SourceMatches. MaxLifeTicks is an absolute backstop + // that bounds per-item CPU for items that never gain mods (normal items) or never resolve at all. + private const int SettleTicks = 120; + private const int MaxLifeTicks = 600; + + // Called every Tick. Re-reads each unfrozen item from a FRESH ItemData and merges the result into + // its snapshot (monotonically — see Merge), then freezes once the source entity is gone or the + // snapshot has settled. + public static void RefreshPool() + { + if (Main.GameController?.InGame != true) return; + + foreach (var g in Pool) + { + if (g.Frozen) continue; + g.AgeTicks++; + + // Source picked up / freed / its slot reused by a different entity: stop reading it and + // keep the best snapshot we built while it was alive. On an immediate pickup the good + // grab-time values are already merged in, and any half-torn-down read since then was + // rejected by Merge — so the values can no longer reset to 0 / garbage. + if (!SourceMatches(g)) + { + Freeze(g); + continue; + } + + // Same live item: re-read it FRESH. A fresh ItemData re-reads the (possibly just-streamed) + // Mods component, so mods that weren't loaded at the grab frame finally appear — this is + // what fixes "some maps never pull their mods". + if (Capture(g)) + g.StableTicks = 0; // snapshot improved this tick; keep watching for more + else + g.StableTicks++; + + // Freeze once the snapshot HAS mods and has stopped improving, or at the absolute backstop. + // Gating on "has mods" is what stops us freezing a class/base-only snapshot before a slow + // Mods component streams in; the backstop still bounds CPU for genuinely mod-less items. + if ((HasMods(g.Chips) && g.StableTicks >= SettleTicks) || g.AgeTicks >= MaxLifeTicks) + Freeze(g); + } + } + + private static bool HasMods(List chips) + { + foreach (var c in chips) if (c.Category == ChipCategory.Mod) return true; + return false; + } + + private static void Freeze(GrabbedItem g) + { + g.Frozen = true; + g.Source = null; // drop the live handle; the snapshot is now final + } + + // True only while the grabbed item's source entity is still the same live item. A freed or reused + // slot reads IsValid=false, Id=0, or a different Id/Path — all of which fail this check. + private static bool SourceMatches(GrabbedItem g) + { + try + { + var ent = g.Source; + return ent != null + && ent.Address == g.SrcAddress + && ent.IsValid + && ent.Id != 0 + && ent.Id == g.SrcId + && ent.Path == g.SrcPath; + } + catch { return false; } + } + + // Re-read the live entity into a FRESH ItemData (so newly-streamed mods/stats are seen) and merge. + // Returns true if the merge improved the snapshot. + private static bool Capture(GrabbedItem g) + { + ItemData data; + try { data = new ItemData(g.Source, Main.GameController); } catch { return false; } + + List fresh; + try { fresh = ChipsForItem(data).ToList(); } catch { return false; } + + return Merge(g, fresh); + } + + // Monotonic merge of a fresh read into the snapshot, keyed by DedupKey: a chip is ADDED when first + // seen and UPGRADED when a higher-quality value arrives, but an already-good value is NEVER replaced + // by a lower-quality one and a chip is NEVER dropped. That is what makes the snapshot stable: a + // teardown read (values gone to 0, or garbage) is quality 0 and simply ignored, while a late read + // that finally carries the real roll upgrades the placeholder. Returns true if anything changed. + private static bool Merge(GrabbedItem g, List fresh) + { + var best = new Dictionary(); + foreach (var c in g.Chips) + if (!string.IsNullOrEmpty(c?.DedupKey)) best[c.DedupKey] = c; + + var changed = false; + foreach (var c in fresh) + { + if (string.IsNullOrEmpty(c?.DedupKey)) continue; + var qf = Quality(c); + if (qf == 0) continue; // drop zero/garbage; a later read upgrades + if (!best.TryGetValue(c.DedupKey, out var ex)) { best[c.DedupKey] = c; changed = true; continue; } + + // Rarity is single-valued and loads late (a "Normal" placeholder before the Mods component + // streams, then the real rarity). Keep the higher rank so the placeholder neither lingers as + // a duplicate chip nor keeps flapping the snapshot (which would stall the settle-freeze). + if (c.Category == ChipCategory.Rarity) + { + if (RarityRank(c.Display) > RarityRank(ex.Display)) { best[c.DedupKey] = c; changed = true; } + continue; + } + + if (Quality(ex) < qf) { best[c.DedupKey] = c; changed = true; } + } + + if (changed) + g.Chips = best.Values.OrderBy(c => c.Category).ThenBy(c => c.Display).ToList(); + return changed; + } + + // Confidence in a chip's value, used to merge monotonically: + // 0 = unusable: garbage text, or a mod whose rolled value is 0 (not yet streamed, or torn down) + // 1 = a present value with no range to verify, or non-zero but outside its declared [min,max] + // 2 = a non-zero value inside the mod's declared [min,max] — a trustworthy roll + // Attribute/scalar chips (class, base, rarity, item level, ...) carry no live mod value; any + // non-garbage one is quality 1 — kept on first sight, never replaced. + private static int Quality(Chip c) + { + if (c == null || LooksGarbage(c)) return 0; + if (c.Mod == null) return 1; + + var t = c.Mod.Threshold; + if (t == 0) return 0; + if (c.Mod.Min is int mn && c.Mod.Max is int mx) + return t >= Math.Min(mn, mx) && t <= Math.Max(mn, mx) ? 2 : 1; + return 1; + } + + // Ordinal rank for the rarity chip's value, so the merge prefers the "more loaded" rarity (the real + // one) over the "Normal" placeholder a not-yet-streamed item briefly reports. + private static int RarityRank(string rarity) => rarity switch + { + "Normal" => 0, + "Magic" => 1, + "Rare" => 2, + "Unique" => 3, + _ => 0, + }; + + // A chip whose text betrays a bad memory read: an unresolved stat description ("", + // a leaked CachedStatDescription / collection ToString), scientific notation, or an absurdly + // large number. Legit map/waystone stats are small integers, so none of these survive a clean read. + private static readonly Regex RxSci = new(@"[eE][+\-]?\d", RegexOptions.Compiled); + private static readonly Regex RxBigNumber = new(@"\d{7,}", RegexOptions.Compiled); + + private static bool LooksGarbage(Chip c) + { + if (LooksGarbageText(c?.Display)) return true; + + // Absurd magnitudes in the roll OR its declared range betray a torn-down read whose number + // still happens to be short enough to dodge the digit-run check (e.g. a stray 5-6 digit value). + if (c?.Mod != null) + { + if (Math.Abs((long)c.Mod.Threshold) > 1_000_000) return true; + if (c.Mod.Min is int mn && Math.Abs((long)mn) > 1_000_000) return true; + if (c.Mod.Max is int mx && Math.Abs((long)mx) > 1_000_000) return true; + } + return false; + } + + // True if a display string betrays a bad read: an unresolved stat description ("", a + // leaked CachedStatDescription / collection ToString), scientific notation, or a >=7-digit run. + private static bool LooksGarbageText(string d) + { + if (string.IsNullOrEmpty(d)) return false; + if (d.IndexOf("= 0) return true; + if (d.IndexOf("CachedStatDescription", StringComparison.Ordinal) >= 0) return true; + if (d.IndexOf("System.Collections", StringComparison.Ordinal) >= 0) return true; + if (RxSci.IsMatch(d)) return true; + if (RxBigNumber.IsMatch(d)) return true; + return false; + } + private static Entity TryGetEntity(Element ui) { if (ui == null || ui.Address == 0) return null; @@ -152,7 +370,8 @@ internal static IEnumerable ChipsForItem(ItemData item) AttributeCatalog.Find("Rarity")?.EnumValues?.Contains(rarityName) == true) yield return new Chip { Category = ChipCategory.Rarity, Display = rarityName, Attribute = "Rarity", Operator = "==", Value = rarityName, - DedupKey = "Rarity:" + rarityName }; + DedupKey = "Rarity" + }; // category-only: an item has ONE rarity; merge keeps the higher rank // Rich header data (the tooltip's "augmented" block): Item Level, Quality, Map tier/rarity/ // quantity/pack size, and aggregated GameStats (Item Rarity, Monster Rarity, Waystone Drop @@ -163,11 +382,11 @@ internal static IEnumerable ChipsForItem(ItemData item) foreach (var chip in stats) yield return chip; - List<(string key, string display)> flags; + List<(string key, string display, string op)> flags; try { flags = BoolFlags(item).ToList(); } catch { flags = []; } - foreach (var (key, display) in flags) + foreach (var (key, display, op) in flags) yield return new Chip { Category = ChipCategory.Flag, Display = display, - Attribute = key, Operator = "has", Value = null, DedupKey = "Flag:" + key }; + Attribute = key, Operator = op, Value = null, DedupKey = "Flag:" + display }; // PoE2 SocketData exposes SocketNumber only (no link size); emit a socket-count chip. var socket = TryRead(() => item.SocketInfo); @@ -191,45 +410,57 @@ private static T TryRead(Func read, T fallback = default) catch { return fallback; } } - private static IEnumerable<(string key, string display)> BoolFlags(ItemData item) + private static IEnumerable<(string key, string display, string op)> BoolFlags(ItemData item) { - if (item.IsCorrupted) yield return ("IsCorrupted", "Corrupted"); - if (item.IsIdentified) yield return ("IsIdentified", "Identified"); - if (item.IsWeapon) yield return ("IsWeapon", "Weapon"); - if (item.Enchanted) yield return ("Enchanted", "Enchanted"); + if (item.IsCorrupted) yield return ("IsCorrupted", "Corrupted", "has"); + + // Identified / Unidentified are the two faces of one bool (compiled to IsIdentified / !IsIdentified). + // Only surface "Unidentified" for rarities that can actually be unidentified (Magic/Rare/Unique): a + // not-yet-streamed item reads !IsIdentified with a placeholder "Normal" rarity, which must not be + // mislabelled as Unidentified (and would otherwise leave a stale chip once the item loads). + if (item.IsIdentified) + yield return ("IsIdentified", "Identified", "has"); + else if (TryRead(() => item.Rarity, ItemRarity.Unknown) is ItemRarity.Magic or ItemRarity.Rare or ItemRarity.Unique) + yield return ("IsIdentified", "Unidentified", "notHas"); + + if (item.IsWeapon) yield return ("IsWeapon", "Weapon", "has"); + if (item.Enchanted) yield return ("Enchanted", "Enchanted", "has"); } private static IEnumerable ModChips(ItemData item) { - // Resolve the item's mods to ItemMod via FindMods over the known mod names. var seen = new HashSet(); - var names = item.ModsNames ?? new List(); - foreach (var name in names) + var chips = new List(); + foreach (var mod in CollectMods(item)) { - List mods; - try { mods = item.FindMods(name); } catch { continue; } - if (mods == null) continue; - - foreach (var mod in mods) + try { - var token = !string.IsNullOrEmpty(mod.Group) ? mod.Group : mod.RawName ?? mod.Name; + if (mod == null) continue; + + var group = TryRead(() => mod.Group); + var token = !string.IsNullOrEmpty(group) ? group : TryRead(() => mod.RawName) ?? TryRead(() => mod.Name); if (string.IsNullOrEmpty(token)) continue; // Skip placeholder/display-only stats (e.g. "DummyStatDisplayNothing"). if (token.IndexOf("Dummy", StringComparison.OrdinalIgnoreCase) >= 0) continue; - if (!seen.Add("Mod:" + token)) continue; - - var display = !string.IsNullOrEmpty(mod.Translation) ? mod.Translation - : !string.IsNullOrEmpty(mod.DisplayName) ? mod.DisplayName : token; - + if (!seen.Add(token)) continue; // dedup the unioned mod lists by identity + + // Read each field defensively. A single mod whose live .Translation read throws OR resolves + // to garbage must still appear — with a fallback label and its real rolled value — instead + // of vanishing + var translation = TryRead(() => mod.Translation); + if (LooksGarbageText(translation)) translation = null; + var displayName = TryRead(() => mod.DisplayName); + var display = !string.IsNullOrEmpty(translation) ? translation + : !string.IsNullOrEmpty(displayName) ? displayName + : Humanize(token); + + var vmm = TryRead(() => mod.ValuesMinMax); int? min = null, max = null; - if (mod.ValuesMinMax is { Length: > 0 }) - { - min = mod.ValuesMinMax[0].Min; - max = mod.ValuesMinMax[0].Max; - } - var rolled = mod.Values is { Count: > 0 } ? mod.Values[0] : 0; + if (vmm is { Length: > 0 }) { min = vmm[0].Min; max = vmm[0].Max; } + var values = TryRead(() => mod.Values); + var rolled = values is { Count: > 0 } ? values[0] : 0; - yield return new Chip + chips.Add(new Chip { Category = ChipCategory.Mod, Display = display, @@ -245,9 +476,47 @@ private static IEnumerable ModChips(ItemData item) Max = max, GameStats = ResolveGameStats(mod), } - }; + }); } + catch { /* one unreadable mod must not drop the rest */ } + } + + return chips; + } + + // Enumerate the item's mods straight from the live Mods component (read fresh each capture so mods + // that stream in after the grab are picked up). Falls back to the ModsNames -> FindMods resolution + // if the component isn't readable. + private static IEnumerable CollectMods(ItemData item) + { + Mods comp = null; + try { comp = item?.Entity?.GetComponent(); } catch { comp = null; } + + if (comp != null) + { + // Union all the component's mod lists, typed lists first so ModChips' first-seen dedup keeps + // their authoritative rolled values; ItemMods last only to backfill any type not yet covered. + var combined = new List(); + void Add(List src) { if (src != null) combined.AddRange(src); } + try { Add(comp.ExplicitMods); } catch { } + try { Add(comp.ImplicitMods); } catch { } + try { Add(comp.EnchantedMods); } catch { } + try { Add(comp.CorruptionImplicitMods); } catch { } + try { Add(comp.SynthesisMods); } catch { } + try { Add(comp.ItemMods); } catch { } + if (combined.Count > 0) return combined; + } + + // Fallback: the original ModsNames -> FindMods resolution. + var viaNames = new List(); + var names = item?.ModsNames ?? new List(); + foreach (var name in names) + { + List found; + try { found = item.FindMods(name); } catch { continue; } + if (found != null) viaNames.AddRange(found); } + return viaNames; } // Best-effort: resolve a mod's GameStat names for stat-mode. Ships returning [] diff --git a/Stashie.cs b/Stashie.cs index 924906a..e88bb8b 100644 --- a/Stashie.cs +++ b/Stashie.cs @@ -154,6 +154,7 @@ public override void Tick() { DynamicIgnore.HandleHotkey(); ItemScanner.HandleGrabHotkey(); + ItemScanner.RefreshPool(); if (!StashingRequirementsMet()) {