diff --git a/Classes/AttributeCatalog.cs b/Classes/AttributeCatalog.cs new file mode 100644 index 0000000..2cffdf8 --- /dev/null +++ b/Classes/AttributeCatalog.cs @@ -0,0 +1,84 @@ +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 = "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 = ["==",">=","<="] }, + + 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/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/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/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/FilterBuilderEditor.cs b/Compartments/FilterBuilderEditor.cs new file mode 100644 index 0000000..2721d87 --- /dev/null +++ b/Compartments/FilterBuilderEditor.cs @@ -0,0 +1,497 @@ +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(GrabbedItem item, bool canAdd, out bool remove) + { + remove = false; + + // 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; + 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 is not (ChipCategory.Mod or ChipCategory.Stat) && MatchesSearch(c))) + { + ImGui.PushID(c.DedupKey); + if (AddButton("+", canAdd)) AddChipToSelected(c); + ImGui.SameLine(); + ImGui.TextUnformatted(AttrLabel(c)); + 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) + { + 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 c61fbd1..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 { @@ -87,6 +113,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 +129,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/Compartments/ItemScanner.cs b/Compartments/ItemScanner.cs new file mode 100644 index 0000000..08373de --- /dev/null +++ b/Compartments/ItemScanner.cs @@ -0,0 +1,653 @@ +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 +using ItemFilterLibrary; // ItemData +using Stashie.Classes; +using static Stashie.StashieCore; + +namespace Stashie.Compartments; + +public enum ChipCategory { Class, Base, Rarity, Stat, 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 +} + +// 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 (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 a frozen-on-settle chip 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; + + 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 + { + // not a valid item + } + } + + // ---- 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; + 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" + }; // 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 + // 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, string op)> flags; + try { flags = BoolFlags(item).ToList(); } catch { flags = []; } + foreach (var (key, display, op) in flags) + yield return new Chip { Category = ChipCategory.Flag, Display = display, + 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); + 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, string op)> BoolFlags(ItemData item) + { + 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) + { + var seen = new HashSet(); + var chips = new List(); + foreach (var mod in CollectMods(item)) + { + try + { + 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(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 (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; + + chips.Add(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), + } + }); + } + 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 [] + // (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(); + } + + // ---- 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 new file mode 100644 index 0000000..3c6d944 --- /dev/null +++ b/Compartments/QueryCompiler.cs @@ -0,0 +1,94 @@ +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] : ""; + // 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") + 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..a120abc 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,75 @@ 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, run the full config reload (rules + tab-assignment menu) so edits + // take effect without a manual "Reload config". + 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) + StashieSettingsHandler.ReloadConfig(); + } - 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 + // 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) StashieSettingsHandler.ReloadConfig(); + _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..256ecda 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,20 +135,27 @@ public static void GenerateTabMenu() ImGui.EndPopup(); } - else - { - indexNode = new ListIndexNode { Value = "Ignore", Index = -1 }; - } + } }; } + // 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); } 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 829bea8..e88bb8b 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,32 @@ public override bool Initialise() Utility.SetupOrClose(); 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 + // 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 +88,8 @@ public override void Render() { try { + DynamicIgnore.DrawIcons(); + if (Settings.InspectInventoryItems) GameController.InspectObject(FilterManager.GetInventoryItems(), "Stashie item data"); } @@ -126,6 +152,10 @@ public override void AreaChange(AreaInstance area) public override void Tick() { + DynamicIgnore.HandleHotkey(); + ItemScanner.HandleGrabHotkey(); + ItemScanner.RefreshPool(); + 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..47cced9 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; @@ -13,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; @@ -46,6 +50,31 @@ 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(); + + [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++; } +} diff --git a/images/lock.png b/images/lock.png new file mode 100644 index 0000000..e9879ef Binary files /dev/null and b/images/lock.png differ