commit c87c3ba8f8a53a1125d086e15f1abdf9acdfa278 Author: Dawnsorrow Date: Sun Feb 15 23:37:05 2026 -0600 Initial release: HSCompare v1.0.2 - WoW-style equipment comparison tooltips Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..415e8c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Build +[Bb]in/ +[Oo]bj/ +[Dd]ebug/ +[Rr]elease/ +*.user +*.suo +*.cache +*.dll +*.pdb +*.zip +packages/ +.vs/ +*.nupkg +**/latest.zip + +# OS +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aa8397f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to HSCompare are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.2] - 2025-02-15 + +### Changed + +- **Alignment**: Comparison tooltip columns are now level. Added a "Hovered item" / "Currently equipped" label row so item names, item level, and all stat rows align horizontally between the two columns. +- **Bonuses**: Substats (bonuses) from the item's BaseParam and BaseParamValue (and HQ BaseParamSpecial / BaseParamValueSpecial) are now included in the comparison and in the stat-difference section below. + +## [1.0.1] - 2025-02-15 + +### Fixed + +- **Equipped item lookup**: Normalize item IDs from the equipped inventory before looking up the Item sheet. IDs above 1,000,000 (e.g. HQ) are now converted to the base item ID, and invalid ranges are skipped, preventing `ArgumentOutOfRangeException` when comparing against equipped gear. + +## [1.0.0] - 2025-02-15 + +### Added + +- WoW-style equipment comparison tooltips when holding a configurable modifier key (default: Shift) over equipment. +- Side-by-side view: hovered item vs. currently equipped item for that slot. +- “If you replace this item, the following stat changes will occur:” section with green (gains) and red (losses) stat deltas. +- Configurable modifier key (Shift / Ctrl / Alt). +- Configurable tooltip font scale, min width, and padding. +- `/hscompare` command to open settings. diff --git a/HSCompare.sln b/HSCompare.sln new file mode 100644 index 0000000..e9f9ba0 --- /dev/null +++ b/HSCompare.sln @@ -0,0 +1,17 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSCompare", "HSCompare\HSCompare.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/HSCompare/Configuration.cs b/HSCompare/Configuration.cs new file mode 100644 index 0000000..0b24bb4 --- /dev/null +++ b/HSCompare/Configuration.cs @@ -0,0 +1,44 @@ +using System; +using Dalamud.Configuration; +using Dalamud.Game.ClientState.Keys; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; + +namespace HSCompare; + +[Serializable] +public class Configuration : IPluginConfiguration +{ + public int Version { get; set; } = 1; + + /// Virtual key that must be held to show comparison (e.g. Shift). + public int ModifierKeyCode { get; set; } = (int)VirtualKey.SHIFT; + + /// Font size scale for the comparison tooltip (1.0 = game default). + public float TooltipFontScale { get; set; } = 1.0f; + + /// Minimum width of the comparison window. 0 = auto. + public float TooltipMinWidth { get; set; } = 320f; + + /// Padding around comparison content. + public float TooltipPadding { get; set; } = 8f; + + [NonSerialized] + private IDalamudPluginInterface? _pluginInterface; + + public void Initialize(IDalamudPluginInterface pluginInterface) + { + _pluginInterface = pluginInterface; + } + + public void Save() + { + _pluginInterface?.SavePluginConfig(this); + } + + public VirtualKey ModifierKey + { + get => (VirtualKey)ModifierKeyCode; + set => ModifierKeyCode = (int)value; + } +} diff --git a/HSCompare/HSCompare.csproj b/HSCompare/HSCompare.csproj new file mode 100644 index 0000000..6b2d11b --- /dev/null +++ b/HSCompare/HSCompare.csproj @@ -0,0 +1,5 @@ + + + 1.0.2 + + diff --git a/HSCompare/HSCompare.json b/HSCompare/HSCompare.json new file mode 100644 index 0000000..ab9ae44 --- /dev/null +++ b/HSCompare/HSCompare.json @@ -0,0 +1,10 @@ +{ + "Name": "HSCompare", + "Author": "HSCompare", + "Punchline": "WoW-style equipment comparison tooltips", + "Description": "When holding a configurable key (default: Shift) over equipment, shows a comparison between the hovered item and the currently equipped item, with total stat difference below (green for gains, red for losses). Configurable font and window size.", + "InternalName": "HSCompare", + "ApplicableVersion": "any", + "Tags": [ "compare", "tooltip", "equipment", "item" ], + "RepoUrl": "http://brassnet.ddns.net:33983/Dawnsorrow/HSCompare" +} diff --git a/HSCompare/InvItem.cs b/HSCompare/InvItem.cs new file mode 100644 index 0000000..e9817ac --- /dev/null +++ b/HSCompare/InvItem.cs @@ -0,0 +1,15 @@ +using Lumina.Excel.Sheets; + +namespace HSCompare; + +public class InvItem +{ + public bool IsHq { get; } + public Item Item { get; } + + public InvItem(Item item, bool isHq) + { + Item = item; + IsHq = isHq; + } +} diff --git a/HSCompare/ItemBonusType.cs b/HSCompare/ItemBonusType.cs new file mode 100644 index 0000000..d450fbc --- /dev/null +++ b/HSCompare/ItemBonusType.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; + +namespace HSCompare; + +internal enum ItemBonusType : byte +{ + [Description("Strength")] STRENGTH = 1, + [Description("Dexterity")] DEXTERITY = 2, + [Description("Vitality")] VITALITY = 3, + [Description("Intelligence")] INTELLIGENCE = 4, + [Description("Mind")] MIND = 5, + [Description("Piety")] PIETY = 6, + [Description("CP")] CP = 11, + [Description("Tenacity")] TENACITY = 19, + [Description("Critical Hit")] CRITICAL_HIT = 20, + [Description("Direct Hit Rate")] DIRECT_HIT_RATE = 22, + [Description("Determination")] DETERMINATION = 44, + [Description("Skill Speed")] SKILL_SPEED = 45, + [Description("Spell Speed")] SPELL_SPEED = 46, + [Description("Craftsmanship")] CRAFTMANSHIP = 70, + [Description("Control")] CONTROL = 71, + [Description("Gathering")] GATHERING = 72, + [Description("Perception")] PERCEPTION = 73, + [Description("Defense")] DEFENSE = 21, + [Description("Magic Defense")] MAGIC_DEFENSE = 24, + [Description("Block Strength")] BLOCK_STRENGTH = 17, + [Description("Block Rate")] BLOCK_RATE = 18, + [Description("Physical Damage")] PHYSICAL_DAMAGE = 12, + [Description("Magic Damage")] MAGIC_DAMAGE = 13, + [Description("Critical Hit (Gathering)")] CRITICAL_HIT_2 = 27, +} + +internal static class ItemBonusTypeExtensions +{ + internal static string ToDescriptionString(this ItemBonusType val) + { + var field = val.GetType().GetField(val.ToString()); + if (field == null) return val.ToString(); + var attrs = (DescriptionAttribute[]?)field.GetCustomAttributes(typeof(DescriptionAttribute), false); + return attrs?.Length > 0 ? attrs[0].Description : val.ToString(); + } +} diff --git a/HSCompare/Plugin.cs b/HSCompare/Plugin.cs new file mode 100644 index 0000000..c62c426 --- /dev/null +++ b/HSCompare/Plugin.cs @@ -0,0 +1,91 @@ +using Dalamud.Game.Command; +using Dalamud.IoC; +using Dalamud.Logging; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Lumina.Excel.Sheets; + +namespace HSCompare; + +public sealed class Plugin : IDalamudPlugin +{ + public string Name => "HSCompare"; + + private const string CommandName = "/hscompare"; + + [PluginService] private static IDalamudPluginInterface PluginInterface { get; set; } = null!; + [PluginService] private static ICommandManager CommandManager { get; set; } = null!; + [PluginService] private static IDataManager Data { get; set; } = null!; + [PluginService] private static IGameGui GameGui { get; set; } = null!; + [PluginService] private static IKeyState KeyState { get; set; } = null!; + [PluginService] private static IClientState ClientState { get; set; } = null!; + [PluginService] private static IGameInventory GameInventory { get; set; } = null!; + [PluginService] private static IPluginLog Log { get; set; } = null!; + + private readonly Configuration _configuration; + private readonly PluginUI _pluginUi; + + public Plugin() + { + _configuration = PluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); + _configuration.Initialize(PluginInterface); + + _pluginUi = new PluginUI(_configuration, KeyState, Data, GameInventory); + + CommandManager.AddHandler(CommandName, new CommandInfo(OnCommand) + { + HelpMessage = "Open HSCompare settings." + }); + + PluginInterface.UiBuilder.Draw += DrawUi; + GameGui.HoveredItemChanged += OnHoveredItemChanged; + } + + private void OnCommand(string command, string args) + { + _pluginUi.ToggleConfig(); + } + + private void OnHoveredItemChanged(object? sender, ulong itemId) + { + if (itemId > 2_000_000) + { + _pluginUi.SetHoveredItem(null); + return; + } + + var isHq = itemId > 1_000_000; + if (isHq) itemId -= 1_000_000; + + var sheet = Data.GetExcelSheet(); + if (sheet == null) + { + _pluginUi.SetHoveredItem(null); + return; + } + + var item = sheet.GetRow((uint)itemId); + _pluginUi.SetHoveredItem(item.RowId == 0 ? null : new InvItem(item, isHq)); + } + + private void DrawUi() + { + if (ClientState?.IsLoggedIn != true) return; + try + { + _pluginUi.Draw(); + } + catch (System.Exception ex) + { + Log.Error(ex, "HSCompare draw error"); + } + } + + public void Dispose() + { + PluginInterface.UiBuilder.Draw -= DrawUi; + GameGui.HoveredItemChanged -= OnHoveredItemChanged; + CommandManager.RemoveHandler(CommandName); + _pluginUi.Dispose(); + } +} diff --git a/HSCompare/PluginUI.cs b/HSCompare/PluginUI.cs new file mode 100644 index 0000000..1987600 --- /dev/null +++ b/HSCompare/PluginUI.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Keys; +using Dalamud.Game.Inventory; +using Dalamud.Interface.Colors; +using Dalamud.Plugin.Services; +using Lumina.Excel.Sheets; + +namespace HSCompare; + +public class PluginUI : IDisposable +{ + private readonly Configuration _config; + private readonly IKeyState _keyState; + private readonly IDataManager _data; + private readonly IGameInventory _gameInventory; + private InvItem? _hoveredItem; + private bool _configVisible; + + public PluginUI(Configuration config, IKeyState keyState, IDataManager data, IGameInventory gameInventory) + { + _config = config; + _keyState = keyState; + _data = data; + _gameInventory = gameInventory; + } + + public void SetHoveredItem(InvItem? item) => _hoveredItem = item; + + public void ToggleConfig() => _configVisible = !_configVisible; + + public void Draw() + { + if (_configVisible) + { + DrawConfigWindow(); + return; + } + + if (!_keyState[_config.ModifierKey]) + return; + + var hovered = _hoveredItem; + if (hovered?.Item == null) + return; + + var armoryType = GetArmoryType(hovered.Item); + if (armoryType == GameInventoryType.Invalid || armoryType == GameInventoryType.ArmorySoulCrystal || armoryType == GameInventoryType.Inventory1) + return; + + var equipped = GetEquippedItemForSlot(armoryType); + if (equipped != null && equipped.Item.RowId == hovered.Item.RowId) + return; + + DrawComparisonWindow(hovered, equipped); + } + + private void DrawConfigWindow() + { + if (!ImGui.Begin("HSCompare Settings", ref _configVisible, ImGuiWindowFlags.AlwaysAutoResize)) + { + ImGui.End(); + return; + } + + ImGui.Text("Modifier key (hold to show comparison):"); + var currentKey = (VirtualKey)_config.ModifierKeyCode; + var keys = new[] { VirtualKey.SHIFT, VirtualKey.CONTROL, VirtualKey.MENU }; + var names = new[] { "Shift", "Ctrl", "Alt" }; + for (var i = 0; i < keys.Length; i++) + { + if (ImGui.RadioButton(names[i], currentKey == keys[i])) + { + _config.ModifierKey = keys[i]; + _config.Save(); + } + if (i < keys.Length - 1) ImGui.SameLine(); + } + + var fontScale = _config.TooltipFontScale; + if (ImGui.SliderFloat("Tooltip font scale", ref fontScale, 0.5f, 2f, "%.1f")) + { + _config.TooltipFontScale = fontScale; + _config.Save(); + } + + var minWidth = _config.TooltipMinWidth; + if (ImGui.SliderFloat("Tooltip min width", ref minWidth, 200f, 600f, "%.0f")) + { + _config.TooltipMinWidth = minWidth; + _config.Save(); + } + + var padding = _config.TooltipPadding; + if (ImGui.SliderFloat("Tooltip padding", ref padding, 4f, 24f, "%.0f")) + { + _config.TooltipPadding = padding; + _config.Save(); + } + + ImGui.Separator(); + ImGui.TextWrapped("Hold the modifier key and hover over equipment to see comparison with your currently equipped item. Stat changes (if you equip the hovered item) appear below in green (gains) and red (losses)."); + ImGui.End(); + } + + private void DrawComparisonWindow(InvItem hovered, InvItem? equipped) + { + var pad = _config.TooltipPadding; + var minW = _config.TooltipMinWidth; + var scale = _config.TooltipFontScale; + + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new System.Numerics.Vector2(pad, pad)); + ImGui.SetNextWindowSizeConstraints(new System.Numerics.Vector2(minW, 0), new System.Numerics.Vector2(800, 2000)); + + if (scale != 1f) + ImGui.GetIO().FontGlobalScale = scale; + + var flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoBringToFrontOnFocus | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNav; + + if (!ImGui.Begin("HSCompare", flags)) + { + if (scale != 1f) ImGui.GetIO().FontGlobalScale = 1f; + ImGui.PopStyleVar(); + return; + } + + // Two columns: hovered item | currently equipped (labels on same line so rows align) + ImGui.Columns(2, "", false); + ImGui.SetColumnWidth(0, minW * 0.5f); + + // Row 1: column labels (same height so item names align) + ImGui.Text("Hovered item"); + ImGui.NextColumn(); + ImGui.Text("Currently equipped"); + ImGui.NextColumn(); + + // Row 2: item names + var nameStr = hovered.Item.Name.ToString() ?? ""; + ImGui.TextColored(ImGui.ColorConvertFloat4ToU32(new System.Numerics.Vector4(0.4f, 1f, 0.4f, 1f)), nameStr); + if (hovered.IsHq) + ImGui.SameLine(); + ImGui.TextColored(ImGui.ColorConvertFloat4ToU32(new System.Numerics.Vector4(1f, 0.84f, 0f, 1f)), hovered.IsHq ? " (HQ)" : ""); + ImGui.NextColumn(); + + if (equipped != null) + { + var eqName = equipped.Item.Name.ToString() ?? ""; + ImGui.TextColored(ImGui.ColorConvertFloat4ToU32(new System.Numerics.Vector4(0.4f, 1f, 0.4f, 1f)), eqName); + if (equipped.IsHq) ImGui.SameLine(); + ImGui.TextColored(ImGui.ColorConvertFloat4ToU32(new System.Numerics.Vector4(1f, 0.84f, 0f, 1f)), equipped.IsHq ? " (HQ)" : ""); + } + else + ImGui.TextColored(ImGuiColors.DalamudGrey, "(Empty slot)"); + ImGui.NextColumn(); + + // Row 3: item level + ImGui.Text($"Item Level {hovered.Item.LevelItem.RowId}"); + ImGui.NextColumn(); + if (equipped != null) + ImGui.Text($"Item Level {equipped.Item.LevelItem.RowId}"); + else + ImGui.Text(""); + ImGui.NextColumn(); + + // Row 4+: main stats and bonuses (aligned: same stat order and line count both sides) + DrawItemStatsAligned(hovered, equipped); + + ImGui.Columns(1); + + // Stat difference section (below) — WoW style + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextColored(ImGui.ColorConvertFloat4ToU32(new System.Numerics.Vector4(1f, 0.92f, 0.4f, 1f)), "If you replace this item, the following stat changes will occur:"); + ImGui.Spacing(); + + var statsHovered = GetItemStats(hovered); + var statsEquipped = equipped != null ? GetItemStats(equipped) : new Dictionary(); + var allKeys = new HashSet(statsHovered.Keys); + foreach (var k in statsEquipped.Keys) allKeys.Add(k); + + var any = false; + foreach (var key in allKeys) + { + var vH = statsHovered.TryGetValue(key, out var h) ? h : (short)0; + var vE = statsEquipped.TryGetValue(key, out var e) ? e : (short)0; + var delta = vH - vE; + if (delta == 0) continue; + any = true; + var name = BaseParamToName(key); + var sign = delta > 0 ? "+" : ""; + var colorU32 = delta > 0 ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; + ImGui.TextColored(colorU32, $"{sign}{delta} {name}"); + } + + if (!any && equipped != null) + ImGui.TextColored(ImGuiColors.DalamudGrey, "No stat change."); + + // Position near cursor (below default tooltip) + var mouse = ImGui.GetMousePos(); + var size = ImGui.GetWindowSize(); + var pos = new System.Numerics.Vector2(mouse.X + 20, mouse.Y + 20); + if (pos.X + size.X > ImGui.GetIO().DisplaySize.X) pos.X = ImGui.GetIO().DisplaySize.X - size.X - 10; + if (pos.Y + size.Y > ImGui.GetIO().DisplaySize.Y) pos.Y = ImGui.GetIO().DisplaySize.Y - size.Y - 10; + if (pos.X < 10) pos.X = 10; + if (pos.Y < 10) pos.Y = 10; + ImGui.SetWindowPos(pos, ImGuiCond.Always); + + ImGui.End(); + if (scale != 1f) ImGui.GetIO().FontGlobalScale = 1f; + ImGui.PopStyleVar(); + } + + private void DrawItemStatsShort(InvItem inv) + { + var stats = GetItemStats(inv); + foreach (var kv in stats) + { + var name = BaseParamToName(kv.Key); + ImGui.Text($"+{kv.Value} {name}"); + } + } + + /// Draw main stats and bonuses for both items with the same stat order so rows align. + private void DrawItemStatsAligned(InvItem hovered, InvItem? equipped) + { + var statsH = GetItemStats(hovered); + var statsE = equipped != null ? GetItemStats(equipped) : new Dictionary(); + var allKeys = new HashSet(statsH.Keys); + foreach (var k in statsE.Keys) allKeys.Add(k); + var orderedKeys = allKeys.OrderBy(k => k).ToList(); + + foreach (var key in orderedKeys) + { + var name = BaseParamToName(key); + var vH = statsH.TryGetValue(key, out var h) ? h : (short)0; + var vE = statsE.TryGetValue(key, out var e) ? e : (short)0; + ImGui.Text(vH != 0 ? $"+{vH} {name}" : ""); + ImGui.NextColumn(); + ImGui.Text(vE != 0 ? $"+{vE} {name}" : ""); + ImGui.NextColumn(); + } + } + + private static GameInventoryType GetArmoryType(Item item) + { + var cat = item.EquipSlotCategory.Value; + if (cat.MainHand == 1) return GameInventoryType.ArmoryMainHand; + if (cat.OffHand == 1) return GameInventoryType.ArmoryOffHand; + if (cat.Head == 1) return GameInventoryType.ArmoryHead; + if (cat.Body == 1) return GameInventoryType.ArmoryBody; + if (cat.Gloves == 1) return GameInventoryType.ArmoryHands; + if (cat.Waist == 1) return GameInventoryType.ArmoryWaist; + if (cat.Legs == 1) return GameInventoryType.ArmoryLegs; + if (cat.Feet == 1) return GameInventoryType.ArmoryFeets; + if (cat.Ears == 1) return GameInventoryType.ArmoryEar; + if (cat.Neck == 1) return GameInventoryType.ArmoryNeck; + if (cat.Wrists == 1) return GameInventoryType.ArmoryWrist; + if (cat.FingerL == 1 || cat.FingerR == 1) return GameInventoryType.ArmoryRings; + if (cat.SoulCrystal == 1) return GameInventoryType.ArmorySoulCrystal; + return GameInventoryType.Inventory1; + } + + private InvItem? GetEquippedItemForSlot(GameInventoryType armoryType) + { + var items = _gameInventory.GetInventoryItems(GameInventoryType.EquippedItems); + var sheet = _data.GetExcelSheet(); + if (sheet == null) return null; + + foreach (var entry in items) + { + var baseItemId = entry.ItemId; + if (baseItemId == 0) continue; + if (baseItemId > 1_000_000) baseItemId -= 1_000_000; // HQ flag + if (baseItemId > 2_000_000) continue; // invalid/collectible etc. + var item = sheet.GetRow(baseItemId); + if (item.RowId == 0) continue; + if (GetArmoryType(item) != armoryType) continue; + return new InvItem(item, entry.IsHq); + } + + return null; + } + + private static Dictionary GetItemStats(InvItem invItem) + { + var bonusMap = new Dictionary(); + + // Main stats (Defense, Damage, Block) + AddMainStat(bonusMap, (byte)ItemBonusType.DEFENSE, (short)invItem.Item.DefensePhys); + AddMainStat(bonusMap, (byte)ItemBonusType.MAGIC_DEFENSE, (short)invItem.Item.DefenseMag); + AddMainStat(bonusMap, (byte)ItemBonusType.PHYSICAL_DAMAGE, (short)invItem.Item.DamagePhys); + AddMainStat(bonusMap, (byte)ItemBonusType.MAGIC_DAMAGE, (short)invItem.Item.DamageMag); + AddMainStat(bonusMap, (byte)ItemBonusType.BLOCK_STRENGTH, (short)invItem.Item.Block); + AddMainStat(bonusMap, (byte)ItemBonusType.BLOCK_RATE, (short)invItem.Item.BlockRate); + + // Bonuses (substats): BaseParam 0..5 and BaseParamValue 0..5 + try + { + var baseParam = invItem.Item.BaseParam; + var baseParamValue = invItem.Item.BaseParamValue; + var n = Math.Min(baseParam.Count, baseParamValue.Count); + for (var i = 0; i < n; i++) + { + var paramRef = baseParam[i]; + var value = (short)baseParamValue[i]; + if (paramRef.RowId == 0 || value == 0) continue; + var rowId = (byte)paramRef.RowId; + if (bonusMap.TryGetValue(rowId, out var existing)) + bonusMap[rowId] = (short)(existing + value); + else + bonusMap[rowId] = value; + } + } + catch { } + + // HQ bonuses: BaseParamSpecial and BaseParamValueSpecial + if (invItem.IsHq) + { + try + { + var baseParamSpecial = invItem.Item.BaseParamSpecial; + var baseParamValueSpecial = invItem.Item.BaseParamValueSpecial; + var n = Math.Min(baseParamSpecial.Count, baseParamValueSpecial.Count); + for (var i = 0; i < n; i++) + { + var paramRef = baseParamSpecial[i]; + var value = (short)baseParamValueSpecial[i]; + if (paramRef.RowId == 0 || value == 0) continue; + var rowId = (byte)paramRef.RowId; + if (bonusMap.TryGetValue(rowId, out var existing)) + bonusMap[rowId] = (short)(existing + value); + else + bonusMap[rowId] = value; + } + } + catch { } + } + + return bonusMap; + } + + private static void AddMainStat(Dictionary map, byte key, short value) + { + if (value <= 0) return; + if (map.TryGetValue(key, out var v)) + map[key] = (short)(v + value); + else + map[key] = value; + } + + private static string BaseParamToName(byte baseParam) + { + if (Enum.IsDefined(typeof(ItemBonusType), baseParam)) + return ((ItemBonusType)baseParam).ToDescriptionString(); + return $"Stat {baseParam}"; + } + + public void Dispose() { } +} diff --git a/HSCompare/packages.lock.json b/HSCompare/packages.lock.json new file mode 100644 index 0000000..d9dfac0 --- /dev/null +++ b/HSCompare/packages.lock.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[14.0.1, )", + "resolved": "14.0.1", + "contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA==" + }, + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + } + } + } +} \ No newline at end of file diff --git a/PLUGIN_LAYOUT.md b/PLUGIN_LAYOUT.md new file mode 100644 index 0000000..e11b20a --- /dev/null +++ b/PLUGIN_LAYOUT.md @@ -0,0 +1,118 @@ +# HSCompare Plugin – Layout and Implementation Plan + +## Overview + +HSCompare is a Dalamud plugin for FFXIV that **replaces or augments equipment tooltips** with a comparison view. When the user hovers over an equipment item (armor/weapon) and holds a configurable modifier key (default: Shift), the plugin shows: + +1. **Hovered item** – the item under the cursor +2. **Currently equipped item** – the item in the same equipment slot +3. **Total stat difference** – below the tooltip, showing the net stat change if the hovered item were equipped (WoW-style: green for gains, red for losses) + +If the hovered item is already equipped, or the item is not equipment, the default game tooltip behavior is unchanged (no replacement). + +--- + +## Architecture + +### Components + +| Component | Purpose | +|-----------|--------| +| **Plugin.cs** | Entry point. Subscribes to `IGameGui.HoveredItemChanged`, registers `/hscompare` command, injects services, and draws UI each frame. | +| **Configuration.cs** | Persisted settings: modifier key (VirtualKey), tooltip font size, comparison window size (width/scale). | +| **PluginUI.cs** | ImGui logic: draws comparison window when conditions are met, applies font/size from config; config window is in the same class. | +| **ItemBonusType.cs** | Enum of FFXIV base param IDs with display names (Strength, Vitality, etc.). | + +### Data Flow + +1. **Hover** → `IGameGui.HoveredItemChanged` fires with item ID (and HQ flag via offset 1_000_000). +2. **Resolve item** → `IDataManager.GetExcelSheet().GetRow(itemId)` → `Item` + HQ flag → wrapped as `InvItem`. +3. **Is equipment?** → `Item.EquipSlotCategory` non-null and not “none” (e.g. MainHand, Head, Body). +4. **Map to slot** → `EquipSlotCategory` → FFXIVClientStructs `InventoryType` (e.g. ArmoryMainHand, ArmoryHead). +5. **Get equipped** → `InventoryManager.Instance()->GetInventoryContainer(InventoryType.EquippedItems)`, scan by slot; get `Item` for same slot. +6. **Compare** → Only show comparison if hovered item is **not** the same as equipped (different RowId or slot). Build stat maps for both items (base params + defense/damage/block), then diff. +7. **Key check** → Each frame in Draw: `IKeyState[Configuration.ModifierKey]` (or fallback to Win32 `GetKeyState` for the configured VK). Only show comparison window when key is held. + +--- + +## Implementation Details + +### 1. Tooltip “replacement” + +- The game’s own tooltip is **not** suppressed (no public Dalamud API to hide it). The plugin draws an **additional** ImGui window when comparison is active. +- **Layout**: One window or two side-by-side: + - **Left**: Hovered item (name, ilvl, main stats, substats). + - **Right**: “Currently Equipped” (same fields). + - **Below** (or bottom section of same window): “If you replace this item, the following stat changes will occur:” then list of stat deltas (e.g. “+12 Strength”, “-5 Critical Hit”) in green/red. +- Position: near cursor or below default tooltip (e.g. cursor + offset so the comparison appears below the game tooltip). + +### 2. Modifier key (configurable) + +- **Config**: Store `VirtualKey` (e.g. `VirtualKey.SHIFT`). Default: Shift. +- **Runtime**: In `PluginUI.Draw()`, check `IKeyState[config.ModifierKey]`. If not pressed, do not draw comparison. +- **Config UI**: Dropdown or key selector for modifier (list common: Shift, Ctrl, Alt). + +### 3. Font and window size (configurable) + +- **Font**: `ImGui.GetIO().Fonts` – push a scaled font (e.g. `ImGui.GetFont()->FontSize * config.TooltipFontScale`) before drawing the comparison window; pop after. +- **Window size**: `ImGui.SetWindowSize()` or use `ImGuiWindowFlags.AlwaysAutoResize` and control width via `ImGui.PushItemWidth()` / text wrap width, or store a “max width” in config and use it for wrapping. Alternatively, a simple “scale” factor that scales both font and padding. + +### 4. Stat difference (total change when equipping hovered item) + +- **Meaning**: “Total stat change” = (hovered item stats) − (equipped item stats). Positive = gain if you equip hovered, negative = loss. +- **Sources for stats**: + - **Substats**: `Item.UnkData59` (BaseParam, BaseParamValue). If HQ, add `Item.UnkData73` (BaseParamValueSpecial) for the same BaseParamSpecial. + - **Main stats**: Defense (Phys/Mag), Damage (Phys/Mag), Block, BlockRate from `Item` (mapped into the same “bonus” map by type, as in SimpleCompare). +- **Display**: One line per stat that has a non-zero delta: “+12 Strength”, “-5 Critical Hit”, “-0.7 Damage Per Second” (if we compute DPS or just “Physical Damage”). Use `ImGui.ColoredText` green for positive, red for negative. +- **Materia**: Optional: show materia slot count difference (e.g. “+1 Materia slot”). + +### 5. Equipment slot mapping + +- Use `Item.EquipSlotCategory.Value` (Lumina): MainHand, OffHand, Head, Body, Hands, Waist, Legs, Feet, Ears, Neck, Wrists, FingerL, FingerR, SoulCrystal. +- Map to `FFXIVClientStructs.FFXIV.Client.Game.InventoryType`: ArmoryMainHand, ArmoryOffHand, ArmoryHead, ArmoryBody, ArmoryHands, ArmoryWaist, ArmoryLegs, ArmoryFeets, ArmoryEar, ArmoryNeck, ArmoryWrist, ArmoryRings (both fingers), ArmorySoulCrystal. +- **Rings**: Two slots; we compare against “the slot this item would go in” – e.g. first ring slot that matches. Simple approach: compare hovered to both equipped rings and show the one that matches slot or the first. + +### 6. Edge cases + +- **Soul crystal / non-equipment**: Ignore (no comparison). +- **Same item equipped**: If hovered item RowId equals equipped item RowId, optionally hide comparison or show “No change”. +- **Empty slot**: If nothing equipped in that slot, show only hovered item and “Currently equipped: (empty)” with stat list = hovered item’s stats as gains. + +--- + +## File Structure + +``` +HSCompare/ +├── HSCompare.sln +├── HSCompare/ +│ ├── HSCompare.csproj +│ ├── HSCompare.json # Manifest template for DalamudPackager +│ ├── Plugin.cs +│ ├── Configuration.cs +│ ├── PluginUI.cs +│ ├── ItemBonusType.cs +└── PLUGIN_LAYOUT.md # This file +``` + +--- + +## Dependencies + +- **Dalamud** (via `$(DalamudLibPath)`): Dalamud.dll, ImGui.NET.dll, ImGuiScene.dll, Lumina.dll, Lumina.Excel.dll, Newtonsoft.Json.dll. +- **FFXIVClientStructs**: For `InventoryManager`, `InventoryType`, `InventoryItem` (equipped item IDs and HQ flag). + +--- + +## Summary + +| Requirement | Implementation | +|-------------|----------------| +| Replace/augment equipment tooltips | Draw ImGui comparison window when modifier held and hovered item is equipment. | +| Compare to equipped when not equipped | Resolve slot from `EquipSlotCategory`, read equipped from `InventoryManager`, show both. | +| Configurable key | Configuration.ModifierKey (VirtualKey), checked via IKeyState. | +| Configurable font and window size | Config: font scale and/or window width; apply in PluginUI before drawing. | +| Total stat difference below tooltip | Section “If you replace this item…” with (hovered − equipped) deltas, green/red. | +| WoW-like comparison | Two blocks (hovered | equipped) + stat change list; color-coded. | + +This layout keeps the plugin modular, testable, and aligned with existing Dalamud and SimpleCompare patterns. diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..e14f64c --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,135 @@ +# Publishing HSCompare to Your Gitea & Dalamud Installer + +This guide covers pushing the plugin to your self-hosted Gitea and making it installable via Dalamud’s plugin installer (custom repository). + +--- + +## 1. Set Your Repo URL (optional but recommended) + +Edit `HSCompare/HSCompare.json` and set `RepoUrl` to your Gitea repo (e.g. `https://gitea.example.com/yourname/HSCompare`). +This is used in the installer and in the plugin master. + +--- + +## 2. Push the Project to Gitea + +1. **Create a new repository** on your Gitea instance (e.g. `HSCompare`). +2. **Initialize git** in the plugin folder (if not already): + ```bash + cd "/path/to/HSCompare" + git init + git add . + git commit -m "Initial commit" + ``` +3. **Add the Gitea remote and push**: + ```bash + git remote add origin https://gitea.example.com/yourname/HSCompare.git + git branch -M main + git push -u origin main + ``` + +--- + +## 3. Build the Release Zip + +From the plugin solution directory: + +```bash +cd "/path/to/HSCompare" +dotnet build -c Release +``` + +The installer-ready zip is produced at: + +- **Path:** `HSCompare/bin/Release/HSCompare/latest.zip` +- **Contents:** `HSCompare.dll`, `HSCompare.json`, and any deps the SDK includes. + +Use this file for every release (e.g. rename to `HSCompare-1.0.2.zip` or keep as `latest.zip`). + +--- + +## 4. Publish a Release on Gitea + +1. In your Gitea repo, open **Releases** → **Create new release**. +2. **Tag:** e.g. `v1.0.2` (match your plugin version). +3. **Title:** e.g. `v1.0.2` or “HSCompare 1.0.2”. +4. **Attach the zip:** upload `HSCompare/bin/Release/HSCompare/latest.zip`. +5. Save the release. + +**Download URL** will look like: + +```text +https://gitea.example.com/yourname/HSCompare/releases/download/v1.0.2/latest.zip +``` + +(Replace `gitea.example.com`, `yourname`, tag and filename if you use another tag or filename.) + +--- + +## 5. Host the Plugin Master (so Dalamud can see it) + +The installer needs a **single JSON file** (plugin master) that lists your plugin and points to the zip. + +1. In the repo root there is a **`pluginmaster.json`** file (see below). +2. **Edit it** once per release: + - Set `DownloadLinkInstall` and `DownloadLinkUpdate` to the **release zip URL** from step 4. + - Set `LastUpdate` to the current **Unix timestamp** (e.g. `date +%s` on Linux/Mac). + - Optionally set `AssemblyVersion` (and `TestingAssemblyVersion` if you have a testing build). +3. Commit and push `pluginmaster.json`: + ```bash + git add pluginmaster.json + git commit -m "Update pluginmaster for v1.0.2" + git push + ``` + +**Raw URL** to the file (for Dalamud) will be something like: + +```text +https://gitea.example.com/yourname/HSCompare/raw/branch/main/pluginmaster.json +``` + +Use `main` or the branch where you store `pluginmaster.json`. + +--- + +## 6. Add the Custom Repo in Dalamud + +1. In-game, run **`/xlsettings`** (or open Dalamud settings). +2. Open the **Experimental** tab. +3. Under **Custom Plugin Repositories**, click **Add**. +4. Paste the **raw plugin master URL** from step 5, e.g.: + ```text + https://gitea.example.com/yourname/HSCompare/raw/main/pluginmaster.json + ``` +5. Save. Your repo will be in the list. + +After that, **HSCompare** should appear in the plugin installer (search or list). Users can install and update from this repo as long as you keep `pluginmaster.json` and the release zip URL correct. + +--- + +## 7. When You Release a New Version + +1. Bump **version** in `HSCompare/HSCompare.csproj` (e.g. `1.0.3`). +2. Update **CHANGELOG.md**. +3. **Build:** `dotnet build -c Release` +4. Create a **new Gitea release** (e.g. tag `v1.0.3`) and attach the new `latest.zip` from `HSCompare/bin/Release/HSCompare/`. +5. In **`pluginmaster.json`** update: + - `DownloadLinkInstall` and `DownloadLinkUpdate` → new zip URL. + - `AssemblyVersion` → e.g. `1.0.3.0`. + - `LastUpdate` → current Unix timestamp. +6. Commit and push the updated `pluginmaster.json`. + +Users who have your repo added will see the update in the installer. + +--- + +## Summary + +| What | Where | +|------|--------| +| Source code | Your Gitea repo (e.g. `HSCompare`) | +| Release zip | Gitea Release attachment (e.g. `latest.zip`) | +| Plugin list for installer | `pluginmaster.json` in repo, served via raw URL | +| Custom repo URL (in Dalamud) | Raw URL to `pluginmaster.json` | + +If your Gitea uses a different URL shape for releases or raw files, adjust the URLs in `pluginmaster.json` and in this guide to match. diff --git a/pluginmaster.json b/pluginmaster.json new file mode 100644 index 0000000..ca68602 --- /dev/null +++ b/pluginmaster.json @@ -0,0 +1,19 @@ +[ + { + "Author": "HSCompare", + "Name": "HSCompare", + "InternalName": "HSCompare", + "AssemblyVersion": "1.0.2.0", + "Description": "When holding a configurable key (default: Shift) over equipment, shows a comparison between the hovered item and the currently equipped item, with total stat difference below (green for gains, red for losses). Configurable font and window size.", + "ApplicableVersion": "any", + "RepoUrl": "https://gitea.example.com/yourname/HSCompare", + "Tags": ["compare", "tooltip", "equipment", "item"], + "DalamudApiLevel": 14, + "Punchline": "WoW-style equipment comparison tooltips", + "IsHide": false, + "IsTestingExclusive": false, + "DownloadLinkInstall": "https://gitea.example.com/yourname/HSCompare/releases/download/v1.0.2/latest.zip", + "DownloadLinkUpdate": "https://gitea.example.com/yourname/HSCompare/releases/download/v1.0.2/latest.zip", + "LastUpdate": "1739664000" + } +]