diff --git a/StarControl/Compatibility/ItemBagsIdentity.cs b/StarControl/Compatibility/ItemBagsIdentity.cs new file mode 100644 index 0000000..ced28be --- /dev/null +++ b/StarControl/Compatibility/ItemBagsIdentity.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using StardewValley; + +namespace StarControl.Compatibility; + +internal static class ItemBagsIdentity +{ + private static Type? itemBagBaseType; + private static MethodInfo? getTypeIdMethod; + + public static string? TryGetBagTypeId(Item item) + { + itemBagBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (itemBagBaseType is null || !itemBagBaseType.IsInstanceOfType(item)) + return null; + + getTypeIdMethod ??= itemBagBaseType.GetMethod( + "GetTypeId", + BindingFlags.Public | BindingFlags.Instance + ); + + if (getTypeIdMethod?.ReturnType != typeof(string)) + return null; + + try + { + return getTypeIdMethod.Invoke(item, null) as string; + } + catch + { + return null; + } + } +} diff --git a/StarControl/Config/QuickSlotConfiguration.cs b/StarControl/Config/QuickSlotConfiguration.cs index 953b89a..a278057 100644 --- a/StarControl/Config/QuickSlotConfiguration.cs +++ b/StarControl/Config/QuickSlotConfiguration.cs @@ -17,6 +17,8 @@ public class QuickSlotConfiguration : IConfigEquatable, /// public string Id { get; set; } = ""; + public string? SubId { get; set; } + /// /// Whether to display a confirmation dialog before activating the item in this slot. /// @@ -42,6 +44,7 @@ public bool Equals(QuickSlotConfiguration? other) } return IdType == other.IdType && Id == other.Id + && SubId == other.SubId && RequireConfirmation == other.RequireConfirmation && UseSecondaryAction == other.UseSecondaryAction; } diff --git a/StarControl/Data/RemappingSlot.cs b/StarControl/Data/RemappingSlot.cs index a5893ae..3321794 100644 --- a/StarControl/Data/RemappingSlot.cs +++ b/StarControl/Data/RemappingSlot.cs @@ -29,4 +29,6 @@ public class RemappingSlot : IItemLookup /// Menu action, depending on the . /// public string Id { get; set; } = ""; + + public string? SubId { get; set; } } diff --git a/StarControl/Graphics/Sprite.cs b/StarControl/Graphics/Sprite.cs index 0e31947..2434948 100644 --- a/StarControl/Graphics/Sprite.cs +++ b/StarControl/Graphics/Sprite.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewValley; namespace StarControl.Graphics; @@ -22,6 +24,78 @@ public static Sprite ForItemId(string id) return new(data.GetTexture(), data.GetSourceRect()); } + public static Sprite FromItem(Item item) + { + ArgumentNullException.ThrowIfNull(item); + + // 1) Prefer the official item registry sprite (works for all vanilla + most mod items that register properly) + var qualifiedId = item.QualifiedItemId; + if (!string.IsNullOrEmpty(qualifiedId)) + { + try + { + var data = ItemRegistry.GetData(qualifiedId); + if (data is not null) + { + return new(data.GetTexture(), data.GetSourceRect()); + } + } + catch + { + // swallow and fall back below + } + } + + // 2) Fallback: render the item into a small texture (works even for "weird" items like Item Bags) + var derivedTexture = TryRenderItemToTexture(item); + if (derivedTexture is not null) + { + return new(derivedTexture, derivedTexture.Bounds); + } + + // 3) Final fallback + return Sprites.Error(); + } + + private static Texture2D? TryRenderItemToTexture(Item item) + { + try + { + var graphicsDevice = Game1.graphics.GraphicsDevice; + var spriteBatch = Game1.spriteBatch; + + var previousTargets = graphicsDevice.GetRenderTargets(); + var renderTarget = new RenderTarget2D(graphicsDevice, 64, 64); + + graphicsDevice.SetRenderTarget(renderTarget); + graphicsDevice.Clear(Color.Transparent); + + spriteBatch.Begin( + SpriteSortMode.Deferred, + BlendState.AlphaBlend, + rasterizerState: new() { MultiSampleAntiAlias = false }, + samplerState: SamplerState.PointClamp + ); + + try + { + // drawInMenu is the most compatible "just draw whatever this item is" API + item.drawInMenu(spriteBatch, Vector2.Zero, 1f); + } + finally + { + spriteBatch.End(); + graphicsDevice.SetRenderTargets(previousTargets); + } + + return renderTarget; + } + catch + { + return null; + } + } + /// /// Attempts to load a sprite from configuration data. /// diff --git a/StarControl/IItemLookup.cs b/StarControl/IItemLookup.cs index b8dd58b..e0283a4 100644 --- a/StarControl/IItemLookup.cs +++ b/StarControl/IItemLookup.cs @@ -11,6 +11,8 @@ public interface IItemLookup /// string Id { get; } + string? SubId { get; } + /// /// The type of ID that the refers to. /// diff --git a/StarControl/Menus/InventoryMenuItem.cs b/StarControl/Menus/InventoryMenuItem.cs index 46d884c..ed1c083 100644 --- a/StarControl/Menus/InventoryMenuItem.cs +++ b/StarControl/Menus/InventoryMenuItem.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using Microsoft.Xna.Framework.Graphics; using StardewValley.Enchantments; @@ -40,7 +41,6 @@ internal class InventoryMenuItem : IRadialMenuItem public InventoryMenuItem(Item item) { - Logger.Log(LogCategory.Menus, "Starting refresh of inventory menu."); Item = item; Title = item.DisplayName; Description = UnparseText(item.getDescription()); @@ -77,6 +77,19 @@ public ItemActivationResult Activate( ItemActivationType activationType ) { + // Item Bags: open bag UI from Primary (wheel) and Instant actions. + if ( + Item is Tool bagTool + && ( + activationType == ItemActivationType.Primary + || activationType == ItemActivationType.Instant + ) + && TryOpenItemBagsMenu(bagTool) + ) + { + return ItemActivationResult.Used; + } + if (activationType == ItemActivationType.Instant) { if (Item is Tool tool) @@ -93,7 +106,7 @@ ItemActivationType activationType { return ItemActivationResult.Ignored; } - who.CurrentToolIndex = who.Items.IndexOf(tool); + who.CurrentToolIndex = toolIndex; } if (tool is FishingRod rod && rod.fishCaught) { @@ -138,6 +151,7 @@ ItemActivationType activationType } } } + return FuzzyActivation.ConsumeOrSelect( who, Item, @@ -338,4 +352,60 @@ private static string UnparseText(string text) } return sb.ToString(); } + + // Cache reflection lookups (don’t re-scan assemblies every press) + private static Type? _itemBagsBaseType; + private static MethodInfo? _openContentsMethod; + + private static bool TryOpenItemBagsMenu(Tool tool) + { + try + { + _itemBagsBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (_itemBagsBaseType is null) + return false; + + if (!_itemBagsBaseType.IsInstanceOfType(tool)) + return false; + + var player = Game1.player; + if (player is null || player.CursorSlotItem is not null) + return false; + + _openContentsMethod ??= _itemBagsBaseType.GetMethod( + "OpenContents", + BindingFlags.Public | BindingFlags.Instance + ); + + if (_openContentsMethod is null) + return false; + + var parameters = _openContentsMethod.GetParameters(); + + if (parameters.Length == 0) + { + _openContentsMethod.Invoke(tool, null); + return true; + } + + if (parameters.Length == 3) + { + _openContentsMethod.Invoke( + tool, + new object?[] { player.Items, player.MaxItems, null } + ); + return true; + } + + return false; + } + catch + { + return false; + } + } } diff --git a/StarControl/Menus/QuickSlotController.cs b/StarControl/Menus/QuickSlotController.cs index 1904860..ebcbc2e 100644 --- a/StarControl/Menus/QuickSlotController.cs +++ b/StarControl/Menus/QuickSlotController.cs @@ -106,7 +106,7 @@ private void RefreshSlots() + $"secondary action = {slot.UseSecondaryAction}, " + $"require confirmation = {slot.RequireConfirmation}" ); - var slottedItem = resolver.ResolveItem(slot.Id, slot.IdType); + var slottedItem = resolver.ResolveItem(slot.Id, slot.SubId, slot.IdType); if (slottedItem is not null) { Logger.Log( diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs index f4b45c0..435dc26 100644 --- a/StarControl/Menus/QuickSlotRenderer.cs +++ b/StarControl/Menus/QuickSlotRenderer.cs @@ -1,7 +1,9 @@ -using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StarControl.Config; +using StarControl.Data; using StarControl.Graphics; +using StardewValley; namespace StarControl.Menus; @@ -49,8 +51,6 @@ private enum PromptPosition (SLOT_DISTANCE + SLOT_SIZE / 2 + MARGIN_OUTER) * 0.7f ); - private static readonly Color OuterBackgroundColor = new(16, 16, 16, 210); - private readonly Dictionary flashes = []; private readonly HashSet enabledSlots = []; private readonly Dictionary slotSprites = []; @@ -99,7 +99,11 @@ public void Draw(SpriteBatch b, Rectangle viewport) leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)), Scale(BACKGROUND_RADIUS) ); - b.Draw(outerBackground, leftBackgroundRect, OuterBackgroundColor * BackgroundOpacity); + b.Draw( + outerBackground, + leftBackgroundRect, + (Color)config.Style.InnerBackgroundColor * BackgroundOpacity + ); DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left); DrawSlot( b, @@ -158,7 +162,11 @@ public void Draw(SpriteBatch b, Rectangle viewport) rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)), Scale(BACKGROUND_RADIUS) ); - b.Draw(outerBackground, rightBackgroundRect, OuterBackgroundColor * BackgroundOpacity); + b.Draw( + outerBackground, + rightBackgroundRect, + (Color)config.Style.InnerBackgroundColor * BackgroundOpacity + ); DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right); DrawSlot( b, @@ -431,15 +439,26 @@ private Texture2D GetUiTexture() return uiTexture; } - private Sprite? GetSlotSprite(IItemLookup itemLookup) + private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection inventoryItems) { if (string.IsNullOrWhiteSpace(itemLookup.Id)) - { return null; - } + return itemLookup.IdType switch { - ItemIdType.GameItem => Sprite.ForItemId(itemLookup.Id), + ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem( + itemLookup.Id, + itemLookup.SubId, + inventoryItems + ) + is Item item + ? Sprite.FromItem(item) // ✅ handles Item Bags via drawInMenu fallback + : ( + ItemRegistry.GetData(itemLookup.Id) is not null + ? Sprite.ForItemId(itemLookup.Id) // only for registered items + : null + ), // unregistered (ItemBags): don't force error icon + ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), _ => null, }; @@ -455,7 +474,7 @@ private void RefreshSlots() { Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data."); enabledSlots.Clear(); - slotSprites.Clear(); + // Keep slotSprites so we can show a "last known" icon when a slot item is temporarily unavailable. foreach (var (button, slotConfig) in Slots) { Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}..."); @@ -492,10 +511,23 @@ private void RefreshSlots() LogLevel.Info ); } - sprite ??= GetSlotSprite(slotConfig); + sprite ??= GetSlotSprite( + slotConfig, + QuickSlotResolver.GetExpandedPlayerItems(Game1.player) + ); if (sprite is not null) { - slotSprites.Add(button, sprite); + slotSprites[button] = sprite; // update/insert + } + else + { + // If we had a previous sprite for this slot, keep it so the icon stays visible (greyed out). + // If we have none, fall back to a generic item-id sprite so something renders. + // (like Item Bags), Sprite.ForItemId will show Error_Invalid, which is worse UX. + if (!slotSprites.ContainsKey(button) && !string.IsNullOrWhiteSpace(slotConfig.Id)) + { + slotSprites[button] = Sprite.ForItemId("Error_Invalid"); + } } } isDirty = false; diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index e38d643..9ab47f1 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -1,4 +1,9 @@ -using System.Reflection; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using StardewValley; using StardewValley.ItemTypeDefinitions; using StardewValley.Tools; @@ -6,8 +11,23 @@ namespace StarControl.Menus; internal class QuickSlotResolver(Farmer player, ModMenu modMenu) { + public static Item? ResolveInventoryItem(string id, string? subId, ICollection items) + { + if (string.IsNullOrEmpty(subId)) + return ResolveInventoryItem(id, items); + + return items.FirstOrDefault(i => + i is not null && i.QualifiedItemId == id && ItemBagsIdentity.TryGetBagTypeId(i) == subId + ); + } + public static Item? ResolveInventoryItem(string id, ICollection items) { + // Allow exact inventory matches even if the item is not registered in ItemRegistry (e.g. Item Bags) + var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); + if (exact is not null) + return exact; + Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); if (ItemRegistry.GetData(id) is not { } data) { @@ -110,6 +130,160 @@ private static bool IsScythe(ParsedItemData data) || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase); } + private static Type? ItemBagType; + private static PropertyInfo? ItemBagContentsProp; + + private static Type? OmniBagType; + private static PropertyInfo? OmniNestedBagsProp; + + private static Type? BundleBagType; + + private static bool TryInitItemBagsReflection() + { + if (ItemBagType is not null && ItemBagContentsProp is not null && BundleBagType is not null) + return true; + + // Find ItemBags.Bags.ItemBag + ItemBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (ItemBagType is null) + return false; + + // public List Contents { get; set; } + ItemBagContentsProp ??= ItemBagType.GetProperty( + "Contents", + BindingFlags.Public | BindingFlags.Instance + ); + if (ItemBagContentsProp is null) + return false; + + // Find BundleBag type so we can EXCLUDE traversing into its contents + BundleBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.BundleBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (BundleBagType is null) + return false; + + // Omni bag support (nested bags) + OmniBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.OmniBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (OmniBagType is not null) + OmniNestedBagsProp ??= OmniBagType.GetProperty( + "NestedBags", + BindingFlags.Public | BindingFlags.Instance + ); + + return true; + } + + internal static bool IsItemBag(Item item) => + ItemBagType is not null && ItemBagType.IsInstanceOfType(item); + + private static bool IsBundleBag(Item item) => + BundleBagType is not null && BundleBagType.IsInstanceOfType(item); + + private static IEnumerable EnumerateBagContents(Item bag) + { + // BundleBag is explicitly excluded + if (IsBundleBag(bag)) + yield break; + + if (ItemBagContentsProp?.GetValue(bag) is not IList list || list.Count == 0) + yield break; + + foreach (var obj in list) + if (obj is Item inner) + yield return inner; + } + + private static IEnumerable EnumerateOmniNestedBags(Item bag) + { + if (OmniBagType is null || OmniNestedBagsProp is null || !OmniBagType.IsInstanceOfType(bag)) + yield break; + + if (OmniNestedBagsProp.GetValue(bag) is not IList list || list.Count == 0) + yield break; + + foreach (var obj in list) + if (obj is Item innerBag) + yield return innerBag; + } + + /// + /// Returns an "effective inventory" which includes: + /// - the player's inventory + /// - contents of any ItemBags bags in the player's inventory (EXCEPT BundleBag contents) + /// - nested bags inside OmniBags (and then their contents too) + /// + public static ICollection GetExpandedPlayerItems(Farmer who) + { + var baseItems = who.Items; + + if (!TryInitItemBagsReflection()) + return baseItems; + + var expanded = new List(baseItems.Count + 16); + + // BFS over bags we discover (supports OmniBag nesting) + var seen = new HashSet(); + var bagQueue = new Queue(); + + // 1) Start with player inventory + foreach (var it in baseItems) + { + if (it is null) + continue; + expanded.Add(it); + + if (IsItemBag(it)) + { + if (seen.Add(it)) + bagQueue.Enqueue(it); + } + } + + // 2) Expand bags: add nested bags (omni) + contents (except bundle) + int depth = 0; + while (bagQueue.Count > 0 && depth < 6) + { + int layer = bagQueue.Count; + for (int i = 0; i < layer; i++) + { + var bag = bagQueue.Dequeue(); + + // Omni nested bags + foreach (var nestedBag in EnumerateOmniNestedBags(bag)) + { + expanded.Add(nestedBag); + if (IsItemBag(nestedBag) && seen.Add(nestedBag)) + bagQueue.Enqueue(nestedBag); + } + + // Bag contents (except BundleBag) + foreach (var innerItem in EnumerateBagContents(bag)) + { + expanded.Add(innerItem); + + // If someone manages to store a bag as an Item (or modded bag item), expand it too. + if (IsItemBag(innerItem) && seen.Add(innerItem)) + bagQueue.Enqueue(innerItem); + } + } + + depth++; + } + + return expanded; + } + public IRadialMenuItem? ResolveItem(string id, ItemIdType idType) { if (string.IsNullOrEmpty(id)) @@ -118,7 +292,24 @@ private static bool IsScythe(ParsedItemData data) } return idType switch { - ItemIdType.GameItem => ResolveInventoryItem(id, player.Items) is { } item + ItemIdType.GameItem => ResolveInventoryItem(id, GetExpandedPlayerItems(player)) + is { } item + ? new InventoryMenuItem(item) + : null, + ItemIdType.ModItem => modMenu.GetItem(id), + _ => null, + }; + } + + public IRadialMenuItem? ResolveItem(string id, string? subId, ItemIdType idType) + { + if (string.IsNullOrEmpty(id)) + return null; + + return idType switch + { + ItemIdType.GameItem => ResolveInventoryItem(id, subId, GetExpandedPlayerItems(player)) + is { } item ? new InventoryMenuItem(item) : null, ItemIdType.ModItem => modMenu.GetItem(id), diff --git a/StarControl/Menus/RemappingController.cs b/StarControl/Menus/RemappingController.cs index 7328419..66ab035 100644 --- a/StarControl/Menus/RemappingController.cs +++ b/StarControl/Menus/RemappingController.cs @@ -238,7 +238,7 @@ private void ResolveSlots() LogCategory.QuickSlots, $"Item data for remapping slot {button}: ID = {slot.IdType}:{slot.Id}" ); - var slottedItem = resolver.ResolveItem(slot.Id, slot.IdType); + var slottedItem = resolver.ResolveItem(slot.Id, slot.SubId, slot.IdType); if (slottedItem is not null) { Logger.Log( diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs index 3c2d6da..751e7a5 100644 --- a/StarControl/UI/QuickSlotConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs @@ -1,6 +1,10 @@ +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using PropertyChanged.SourceGenerator; using StarControl.Graphics; +using StarControl.Menus; +using StardewValley; using StardewValley.ItemTypeDefinitions; namespace StarControl.UI; @@ -10,6 +14,11 @@ internal partial class QuickSlotConfigurationViewModel private static readonly Color AssignedColor = new(50, 100, 50); private static readonly Color UnassignedColor = new(60, 60, 60); private static readonly Color UnavailableColor = new(0x44, 0x44, 0x44, 0x44); + private static readonly Dictionary LastKnownIcons = new(); + private static readonly Dictionary LastKnownTooltips = new(); + + private string IconCacheKey => + ItemData is null ? "" : $"{ItemData.QualifiedItemId}::{ItemSubId ?? ""}"; public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor; public string CurrentAssignmentLabel => @@ -21,10 +30,23 @@ internal partial class QuickSlotConfigurationViewModel public Sprite? Icon => GetIcon(); public bool IsAssigned => ItemData is not null || ModAction is not null; - public Color Tint => - ItemData is not null && Game1.player.Items.FindUsableItem(ItemData.QualifiedItemId) is null - ? UnavailableColor - : Color.White; + public Color Tint + { + get + { + if (ItemData is null || Game1.player is null) + return Color.White; + + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var resolved = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + return resolved is null ? UnavailableColor : Color.White; + } + } [DependsOn(nameof(ItemData), nameof(ModAction))] public TooltipData Tooltip => GetTooltip(); @@ -32,6 +54,9 @@ internal partial class QuickSlotConfigurationViewModel [Notify] private ParsedItemData? itemData; + [Notify] + private string? itemSubId; + [Notify] private ModMenuItemConfigurationViewModel? modAction; @@ -46,31 +71,84 @@ public void Clear() ItemData = null; ModAction = null; UseSecondaryAction = false; + ItemSubId = null; } private Sprite? GetIcon() { - return ItemData is not null - ? new(ItemData.GetTexture(), ItemData.GetSourceRect()) - : ModAction?.Icon; + if (ItemData is not null) + { + // Use expanded inventory so Item Bags + OmniBag nested bags resolve. + if (Game1.player is not null) + { + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var invItem = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + if (invItem is not null) + { + var sprite = Sprite.FromItem(invItem); + LastKnownIcons[IconCacheKey] = sprite; + return sprite; + } + } + + // If it’s an error item (common for Item Bags), try last-known icon before falling back. + if (ItemData.IsErrorItem && LastKnownIcons.TryGetValue(IconCacheKey, out var cached)) + return cached; + + // Normal path for registered items + return new(ItemData.GetTexture(), ItemData.GetSourceRect()); + } + + return ModAction?.Icon; } private TooltipData GetTooltip() { if (ItemData is not null) { - return new( - Title: ItemData.DisplayName, - Text: ItemData.Description, - Item: ItemRegistry.Create(ItemData.QualifiedItemId) - ); + // Prefer a real item instance from expanded inventory (bags + omni) + if (Game1.player is not null) + { + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var invItem = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + if (invItem is not null) + { + var tip = new TooltipData( + Title: invItem.DisplayName, + Text: invItem.getDescription(), + Item: invItem + ); + + LastKnownTooltips[IconCacheKey] = tip; + return tip; + } + } + + // IMPORTANT: do NOT call ItemRegistry.Create here for error/unresolvable items. + if (LastKnownTooltips.TryGetValue(IconCacheKey, out var cachedTip)) + return cachedTip; + + // That’s what is throwing in your SMAPI log and breaking the UI binding updates. + return new(Title: ItemData.DisplayName, Text: ItemData.Description); } + if (ModAction is not null) { return !string.IsNullOrEmpty(ModAction.Description) ? new(Title: ModAction.Name, Text: ModAction.Description) : new(ModAction.Name); } + return new(I18n.Config_QuickActions_EmptySlot_Title()); } diff --git a/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs b/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs index 4d5adeb..b1ea333 100644 --- a/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs @@ -72,6 +72,7 @@ IReadOnlyCollection modMenuPages { case ItemIdType.GameItem: target.ItemData = ItemRegistry.GetDataOrErrorItem(config.Id); + target.ItemSubId = config.SubId; break; case ItemIdType.ModItem: target.ModAction = modMenuPages @@ -112,6 +113,7 @@ IDictionary configs { IdType = target.ItemData is not null ? ItemIdType.GameItem : ItemIdType.ModItem, Id = target.ItemData?.QualifiedItemId ?? target.ModAction?.Id ?? "", + SubId = target.ItemData is not null ? target.ItemSubId : null, RequireConfirmation = target.RequireConfirmation, UseSecondaryAction = target.UseSecondaryAction, }; diff --git a/StarControl/UI/QuickSlotPickerItemViewModel.cs b/StarControl/UI/QuickSlotPickerItemViewModel.cs index bb5f34f..af9f58c 100644 --- a/StarControl/UI/QuickSlotPickerItemViewModel.cs +++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs @@ -1,5 +1,6 @@ using Microsoft.Xna.Framework.Graphics; using StarControl.Graphics; +using StardewValley; using StardewValley.ItemTypeDefinitions; using StardewValley.Objects; @@ -69,18 +70,46 @@ internal class QuickSlotPickerItemViewModel( /// The item to display. public static QuickSlotPickerItemViewModel ForItem(Item item) { + // Keep SmokedFish behavior exactly as before (special tint/overlay logic) var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); if (item is SObject obj && obj.preserve.Value == SObject.PreserveType.SmokedFish) { var fishData = ItemRegistry.GetDataOrErrorItem(obj.GetPreservedItemId()); return new( - slot => slot.ItemData = data, + slot => + { + slot.ItemData = data; + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, fishData.GetTexture(), fishData.GetSourceRect(), fishData.GetSourceRect(), SmokedFishTintColor ); } + + // Tooltip is shared by both paths + TooltipData tooltip = !string.IsNullOrEmpty(item.getDescription()) + ? new(Title: item.DisplayName, Text: item.getDescription(), Item: item) + : new(Text: item.getDescription(), Item: item); + + // If the registry data is an error item AND the item isn't vanilla, render via drawInMenu. + if (data.IsErrorItem && item.GetType().Assembly != typeof(SObject).Assembly) + { + var sprite = Sprite.FromItem(item); + return new( + slot => + { + slot.ItemData = data; + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, + sprite.Texture, + sprite.SourceRect, + tooltip: tooltip + ); + } + + // Normal (registered) items keep tint/overlay behavior Color? tintColor = null; Rectangle? tintRect = null; if (item is ColoredObject co) @@ -91,11 +120,13 @@ public static QuickSlotPickerItemViewModel ForItem(Item item) tintRect = data.GetSourceRect(1); } } - TooltipData tooltip = !string.IsNullOrEmpty(item.getDescription()) - ? new(Title: item.DisplayName, Text: item.getDescription(), Item: item) - : new(Text: item.getDescription(), Item: item); + return new( - slot => slot.ItemData = data.GetBaseItem(), + slot => + { + slot.ItemData = data.GetBaseItem(); + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, data.GetTexture(), data.GetSourceRect(), tintRect, diff --git a/StarControl/UI/QuickSlotPickerViewModel.cs b/StarControl/UI/QuickSlotPickerViewModel.cs index 6e60a5b..52b1dca 100644 --- a/StarControl/UI/QuickSlotPickerViewModel.cs +++ b/StarControl/UI/QuickSlotPickerViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using PropertyChanged.SourceGenerator; +using StarControl.Menus; using StardewValley.ItemTypeDefinitions; namespace StarControl.UI; @@ -28,10 +29,11 @@ public Vector2 ContentPanelSize public bool HasMoreModItems => ModMenuPageIndex < 0 && ModMenuPages.Sum(page => page.Items.Count) > MAX_RESULTS; public IEnumerable InventoryItems => - Game1 - .player.Items.Where(item => item is not null) - .Select(QuickSlotPickerItemViewModel.ForItem) - .ToArray(); + Game1.player is null + ? Array.Empty() + : GetInventoryAndNestedBags(Game1.player) + .Select(QuickSlotPickerItemViewModel.ForItem) + .ToArray(); public EnumSegmentsViewModel ItemSource { get; } = new(); public IEnumerable ModMenuItems => ( @@ -184,4 +186,35 @@ private void UpdateSearchResults() cancellationToken ); } + + private static IEnumerable GetInventoryAndNestedBags(Farmer who) + { + // Base inventory items (tools, objects, the OmniBag item itself, etc.) + var result = new List(who.Items.Count + 8); + var seen = new HashSet(); + + foreach (var it in who.Items) + { + if (it is null) + continue; + + result.Add(it); + seen.Add(it); + } + + // Add nested bags from OmniBags, but DO NOT add bag contents + foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) + { + if (it is null) + continue; + + if (!QuickSlotResolver.IsItemBag(it)) + continue; + + if (seen.Add(it)) + result.Add(it); + } + + return result; + } } diff --git a/StarControl/UI/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs index 710e673..c7e6711 100644 --- a/StarControl/UI/RemappingViewModel.cs +++ b/StarControl/UI/RemappingViewModel.cs @@ -37,8 +37,7 @@ Action> onSave new() { Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(), - Items = who - .Items.Where(item => item is not null) + Items = GetInventoryAndNestedBags(who) .Select(RemappableItemViewModel.FromInventoryItem) .ToList(), }, @@ -130,10 +129,21 @@ public void Load(Dictionary data) var item = slotData.IdType switch { ItemIdType.GameItem => ItemGroups[0] - .Items.FirstOrDefault(item => item.Id == slotData.Id) - ?? RemappableItemViewModel.FromInventoryItem( - ItemRegistry.Create(slotData.Id), - who.Items + .Items.FirstOrDefault(item => + item.Id == slotData.Id && item.SubId == slotData.SubId + ) + ?? ( + QuickSlotResolver.ResolveInventoryItem( + slotData.Id, + slotData.SubId, + who.Items + ) + is Item invItem + ? RemappableItemViewModel.FromInventoryItem( + invItem, + QuickSlotResolver.GetExpandedPlayerItems(who) + ) + : null ), ItemIdType.ModItem => ItemGroups[1] .Items.FirstOrDefault(item => item.Id == slotData.Id), @@ -153,7 +163,12 @@ public void Save() { if (slot.Item is { } item && !string.IsNullOrEmpty(item.Id)) { - data[button] = new() { Id = slot.Item.Id, IdType = slot.Item.IdType }; + data[button] = new() + { + Id = slot.Item.Id, + SubId = slot.Item.SubId, + IdType = slot.Item.IdType, + }; } } onSave(data); @@ -365,6 +380,35 @@ private bool TryScrollActiveContainer(bool scrollUp) } return null; } + + private static IEnumerable GetInventoryAndNestedBags(Farmer who) + { + var result = new List(who.Items.Count + 8); + var seen = new HashSet(); + + foreach (var it in who.Items) + { + if (it is null) + continue; + + result.Add(it); + seen.Add(it); + } + + foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) + { + if (it is null) + continue; + + if (!QuickSlotResolver.IsItemBag(it)) + continue; + + if (seen.Add(it)) + result.Add(it); + } + + return result; + } } internal partial class RemappingSlotViewModel(SButton button) @@ -396,6 +440,8 @@ internal partial class RemappableItemViewModel { public string Id { get; init; } = ""; + public string? SubId { get; init; } + public ItemIdType IdType { get; init; } public bool IsCountVisible => Count > 1; @@ -422,13 +468,18 @@ internal partial class RemappableItemViewModel public static RemappableItemViewModel FromInventoryItem(Item item) { - var itemData = ItemRegistry.GetData(item.QualifiedItemId); + ArgumentNullException.ThrowIfNull(item); + + var qualifiedId = item.QualifiedItemId; // can be null/empty for some mod items + var sprite = Sprite.FromItem(item); + return new() { - Id = item.QualifiedItemId, + Id = qualifiedId ?? item.Name ?? item.GetType().FullName ?? "unknown", IdType = ItemIdType.GameItem, + SubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item), Enabled = true, - Sprite = new(itemData.GetTexture(), itemData.GetSourceRect()), + Sprite = sprite, Quality = item.Quality, Count = item.Stack, Tooltip = new(item.getDescription(), item.DisplayName, item), @@ -442,7 +493,11 @@ ICollection availableItems { var result = FromInventoryItem(item); result.Enabled = - QuickSlotResolver.ResolveInventoryItem(item.QualifiedItemId, availableItems) + QuickSlotResolver.ResolveInventoryItem( + item.QualifiedItemId, + result.SubId, + availableItems + ) is not null; return result; }