From 533ec65a45ad2eafa0da90c12cf3faf71f6443ea Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Fri, 23 Jan 2026 22:50:25 -0500
Subject: [PATCH 01/24] Added Weird Items Case
---
StarControl/Graphics/Sprite.cs | 74 ++++++++++++++++++++++++++++++++++
1 file changed, 74 insertions(+)
diff --git a/StarControl/Graphics/Sprite.cs b/StarControl/Graphics/Sprite.cs
index 0e31947..ac7a3e9 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;
@@ -21,7 +23,79 @@ public static Sprite ForItemId(string id)
var data = ItemRegistry.GetDataOrErrorItem(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.
///
From 738ba6056a552e9c3d96474c438bea7e7ca52de4 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Fri, 23 Jan 2026 22:58:00 -0500
Subject: [PATCH 02/24] Weird item case
---
StarControl/UI/RemappingViewModel.cs | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/StarControl/UI/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs
index 710e673..662be70 100644
--- a/StarControl/UI/RemappingViewModel.cs
+++ b/StarControl/UI/RemappingViewModel.cs
@@ -422,13 +422,17 @@ 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,
Enabled = true,
- Sprite = new(itemData.GetTexture(), itemData.GetSourceRect()),
+ Sprite = sprite,
Quality = item.Quality,
Count = item.Stack,
Tooltip = new(item.getDescription(), item.DisplayName, item),
From e1ada1c1bd9755ffad67a0f6d2f76f0d886307de Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 03:15:05 -0500
Subject: [PATCH 03/24] I found the bugger
---
StarControl/Menus/QuickSlotResolver.cs | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs
index e38d643..0f3d058 100644
--- a/StarControl/Menus/QuickSlotResolver.cs
+++ b/StarControl/Menus/QuickSlotResolver.cs
@@ -8,6 +8,11 @@ internal class QuickSlotResolver(Farmer player, ModMenu modMenu)
{
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)
{
From 6f6bef737aced1ac25b6d1e633aad307f9fd2f19 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 03:29:15 -0500
Subject: [PATCH 04/24] Get quick menu to pick up Icon
---
StarControl/Menus/QuickSlotRenderer.cs | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs
index f4b45c0..c6a04bc 100644
--- a/StarControl/Menus/QuickSlotRenderer.cs
+++ b/StarControl/Menus/QuickSlotRenderer.cs
@@ -1,7 +1,9 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using StarControl.Config;
+using StarControl.Data;
using StarControl.Graphics;
+using StardewValley;
namespace StarControl.Menus;
@@ -431,20 +433,27 @@ 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,
+ inventoryItems
+ )
+ is Item item
+ ? Sprite.FromItem(item) // handles Item Bags via drawInMenu fallback
+ : Sprite.ForItemId(itemLookup.Id), // fallback for normal items
+
ItemIdType.ModItem => GetModItemSprite(itemLookup.Id),
_ => null,
};
}
+
private static Color LumaGray(Color color, float lightness)
{
var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness);
@@ -492,7 +501,7 @@ private void RefreshSlots()
LogLevel.Info
);
}
- sprite ??= GetSlotSprite(slotConfig);
+ sprite ??= GetSlotSprite(slotConfig, Game1.player.Items);
if (sprite is not null)
{
slotSprites.Add(button, sprite);
From 4ee324cf583f640d65e1cb24e1193b015ad525d8 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 03:38:25 -0500
Subject: [PATCH 05/24] Wheels and such
---
StarControl/Menus/InventoryMenuItem.cs | 752 ++++++++++++++-----------
1 file changed, 411 insertions(+), 341 deletions(-)
diff --git a/StarControl/Menus/InventoryMenuItem.cs b/StarControl/Menus/InventoryMenuItem.cs
index 46d884c..363a4d9 100644
--- a/StarControl/Menus/InventoryMenuItem.cs
+++ b/StarControl/Menus/InventoryMenuItem.cs
@@ -1,341 +1,411 @@
-using System.Runtime.CompilerServices;
-using System.Text;
-using Microsoft.Xna.Framework.Graphics;
-using StardewValley.Enchantments;
-using StardewValley.ItemTypeDefinitions;
-using StardewValley.Objects;
-using StardewValley.Tools;
-
-namespace StarControl.Menus;
-
-///
-/// A menu item that corresponds to an item in the player's inventory.
-///
-internal class InventoryMenuItem : IRadialMenuItem
-{
- public string Id => Item.QualifiedItemId;
-
- ///
- /// The underlying inventory item.
- ///
- public Item Item { get; }
-
- public string Title { get; }
-
- public string Description { get; }
-
- public int? StackSize => Item.maximumStackSize() > 1 ? Item.Stack : null;
-
- public int? Quality => Item.Quality;
-
- public Texture2D? Texture { get; }
-
- public Rectangle? SourceRectangle { get; }
-
- public Rectangle? TintRectangle { get; }
-
- public Color? TintColor { get; }
-
- private static readonly ConditionalWeakTable
- DerivedTextures = [];
-
- public InventoryMenuItem(Item item)
- {
- Logger.Log(LogCategory.Menus, "Starting refresh of inventory menu.");
- Item = item;
- Title = item.DisplayName;
- Description = UnparseText(item.getDescription());
-
- var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId);
- // Workaround for some mods creating items without registering item data; these mods
- // typically only implement drawInMenu. This is not a good thing to do, and tends to cause
- // other problems, but we can still attempt to show an icon.
- if (
- data.IsErrorItem
- // Exclude vanilla item classes; hacked items only apply to mods, and if we found an
- // instance with a vanilla type, it means the item truly is missing (e.g. source mod is
- // no longer installed, or the item is simply invalid) and we'd only waste time trying
- // to derive a texture.
- && item.GetType().Assembly != typeof(SObject).Assembly
- && GetDerivedTexture(item) is { } texture
- )
- {
- Texture = texture;
- SourceRectangle = texture.Bounds;
- }
- else
- {
- var textureData = GetTextureRedirect(item);
- Texture = textureData?.GetTexture() ?? data.GetTexture();
- SourceRectangle = textureData?.GetSourceRect() ?? data.GetSourceRect();
- (TintRectangle, TintColor) = GetTinting(item, textureData ?? data);
- }
- }
-
- public ItemActivationResult Activate(
- Farmer who,
- DelayedActions delayedActions,
- ItemActivationType activationType
- )
- {
- if (activationType == ItemActivationType.Instant)
- {
- if (Item is Tool tool)
- {
- var isActivating = IsActivating();
- if (!isActivating && (!Context.CanPlayerMove || who.canOnlyWalk || who.UsingTool))
- {
- return ItemActivationResult.Ignored;
- }
- if (who.CurrentTool != tool)
- {
- var toolIndex = who.Items.IndexOf(tool);
- if (toolIndex < 0)
- {
- return ItemActivationResult.Ignored;
- }
- who.CurrentToolIndex = who.Items.IndexOf(tool);
- }
- if (tool is FishingRod rod && rod.fishCaught)
- {
- rod.doneHoldingFish(who);
- return ItemActivationResult.Used;
- }
- else if (tool is not MeleeWeapon)
- {
- who.FireTool();
- }
- Game1.pressUseToolButton();
- return isActivating
- ? ItemActivationResult.Used
- : ItemActivationResult.ToolUseStarted;
- }
- else if (Item is SObject obj && obj.isPlaceable())
- {
- var previousToolIndex = who.CurrentToolIndex;
- var grabTile = Game1.GetPlacementGrabTile();
- var placementPosition = Utility.GetNearbyValidPlacementPosition(
- who,
- who.currentLocation,
- obj,
- (int)grabTile.X * 64,
- (int)grabTile.Y * 64
- );
- try
- {
- who.CurrentToolIndex = who.Items.IndexOf(obj);
- return Utility.tryToPlaceItem(
- who.currentLocation,
- obj,
- (int)placementPosition.X,
- (int)placementPosition.Y
- )
- ? ItemActivationResult.Used
- : ItemActivationResult.Ignored;
- }
- finally
- {
- who.CurrentToolIndex = previousToolIndex;
- }
- }
- }
- return FuzzyActivation.ConsumeOrSelect(
- who,
- Item,
- delayedActions,
- activationType == ItemActivationType.Secondary
- ? InventoryAction.Select
- : InventoryAction.Use
- );
- }
-
- public void ContinueActivation()
- {
- var who = Game1.player;
- if (Item is not Tool tool || who.CurrentTool != tool)
- {
- return;
- }
- if (!who.canReleaseTool || who.Stamina < 1 || tool is FishingRod)
- {
- return;
- }
- var maxPowerModifier = tool.hasEnchantmentOfType() ? 1 : 0;
- var maxPower = tool.UpgradeLevel + maxPowerModifier;
- if (who.toolPower.Value >= maxPower)
- {
- return;
- }
- if (who.toolHold.Value <= 0)
- {
- who.toolHold.Value = (int)(tool.AnimationSpeedModifier * 600);
- }
- else
- {
- who.toolHold.Value -= Game1.currentGameTime.ElapsedGameTime.Milliseconds;
- if (who.toolHold.Value <= 0)
- {
- who.toolPowerIncrease();
- }
- }
- }
-
- public bool EndActivation()
- {
- var who = Game1.player;
- if (Item is Tool tool && who.CurrentTool == tool && who.UsingTool && who.canReleaseTool)
- {
- if (Item is not FishingRod)
- {
- who.EndUsingTool();
- }
- return true;
- }
- // This isn't equivalent to vanilla logic, but if we detect that the player is no longer
- // using ANY tool (which is what UsingTool tells us) then any button that the controller is
- // "holding" should be released anyway, so that it can be pressed again.
- if (!who.UsingTool)
- {
- return true;
- }
- return false;
- }
-
- public string? GetActivationSound(
- Farmer who,
- ItemActivationType activationType,
- string defaultSound
- )
- {
- return Item is Tool && activationType == ItemActivationType.Instant ? null : defaultSound;
- }
-
- private static Texture2D? GetDerivedTexture(Item item)
- {
- if (!DerivedTextures.TryGetValue(item, out var texture))
- {
- 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
- {
- item.drawInMenu(spriteBatch, Vector2.Zero, 1f);
- texture = renderTarget;
- DerivedTextures.Add(item, texture);
- }
- catch
- {
- renderTarget.Dispose();
- throw;
- }
- finally
- {
- spriteBatch.End();
- graphicsDevice.SetRenderTargets(previousTargets);
- }
- }
- catch (Exception ex)
- {
- Logger.Log(
- $"Error deriving texture for item '{item.Name}' ({item.QualifiedItemId}): {ex}",
- LogLevel.Error
- );
- }
- }
- return texture;
- }
-
- public bool IsActivating()
- {
- return Item is Tool tool && Game1.player.CurrentTool == tool && Game1.player.UsingTool;
- }
-
- private static ParsedItemData? GetTextureRedirect(Item item)
- {
- return item is SObject obj && item.ItemId == "SmokedFish"
- ? ItemRegistry.GetData(obj.preservedParentSheetIndex.Value)
- : null;
- }
-
- private static (Rectangle? tintRect, Color? tintColor) GetTinting(
- Item item,
- ParsedItemData data
- )
- {
- if (item is not ColoredObject coloredObject)
- {
- return default;
- }
- if (item.ItemId == "SmokedFish")
- {
- // Smoked fish implementation is unique (and private) in ColoredObject.
- // We don't care about the animation here, but should draw it darkened; the quirky
- // way this is implemented is to draw a tinted version of the original item sprite
- // (not an overlay) sprite over top of the original sprite.
- return (data.GetSourceRect(), new Color(80, 30, 10) * 0.6f);
- }
- return !coloredObject.ColorSameIndexAsParentSheetIndex
- ? (data.GetSourceRect(1), coloredObject.color.Value)
- : (null, coloredObject.color.Value);
- }
-
- // When we call Item.getDescription(), most implementations go through `Game1.parseText`
- // which splits the string itself onto multiple lines. This tries to remove that, so that we
- // can do our own wrapping using our own width.
- //
- // N.B. The reason we don't just use `ParsedItemData.Description` is that, at least in the
- // current version, it's often only a "base description" and includes format placeholders,
- // or is missing suffixes.
- private static string UnparseText(string text)
- {
- var sb = new StringBuilder();
- var isWhitespace = false;
- var newlineCount = 0;
- foreach (var c in text)
- {
- if (c == ' ' || c == '\r' || c == '\n')
- {
- if (!isWhitespace)
- {
- sb.Append(' ');
- }
- isWhitespace = true;
- if (c == '\n')
- {
- newlineCount++;
- }
- }
- else
- {
- // If the original text has a "paragraph", the formatted text will often look
- // strange if that is also collapsed into a space. So preserve _multiple_
- // newlines somewhat as a single "paragraph break".
- if (newlineCount > 1)
- {
- // From implementation above, newlines are counted as whitespace so we know
- // that the last character will always be a space when hitting here.
- sb.Length--;
- sb.Append("\r\n\r\n");
- }
- sb.Append(c);
- isWhitespace = false;
- newlineCount = 0;
- }
- }
- if (isWhitespace)
- {
- sb.Length--;
- }
- return sb.ToString();
- }
-}
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text;
+using Microsoft.Xna.Framework.Graphics;
+using StardewValley.Enchantments;
+using StardewValley.ItemTypeDefinitions;
+using StardewValley.Objects;
+using StardewValley.Tools;
+
+namespace StarControl.Menus;
+
+///
+/// A menu item that corresponds to an item in the player's inventory.
+///
+internal class InventoryMenuItem : IRadialMenuItem
+{
+ public string Id => Item.QualifiedItemId;
+
+ ///
+ /// The underlying inventory item.
+ ///
+ public Item Item { get; }
+
+ public string Title { get; }
+
+ public string Description { get; }
+
+ public int? StackSize => Item.maximumStackSize() > 1 ? Item.Stack : null;
+
+ public int? Quality => Item.Quality;
+
+ public Texture2D? Texture { get; }
+
+ public Rectangle? SourceRectangle { get; }
+
+ public Rectangle? TintRectangle { get; }
+
+ public Color? TintColor { get; }
+
+ private static readonly ConditionalWeakTable
- DerivedTextures = [];
+
+ public InventoryMenuItem(Item item)
+ {
+ Item = item;
+ Title = item.DisplayName;
+ Description = UnparseText(item.getDescription());
+
+ var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId);
+ // Workaround for some mods creating items without registering item data; these mods
+ // typically only implement drawInMenu. This is not a good thing to do, and tends to cause
+ // other problems, but we can still attempt to show an icon.
+ if (
+ data.IsErrorItem
+ // Exclude vanilla item classes; hacked items only apply to mods, and if we found an
+ // instance with a vanilla type, it means the item truly is missing (e.g. source mod is
+ // no longer installed, or the item is simply invalid) and we'd only waste time trying
+ // to derive a texture.
+ && item.GetType().Assembly != typeof(SObject).Assembly
+ && GetDerivedTexture(item) is { } texture
+ )
+ {
+ Texture = texture;
+ SourceRectangle = texture.Bounds;
+ }
+ else
+ {
+ var textureData = GetTextureRedirect(item);
+ Texture = textureData?.GetTexture() ?? data.GetTexture();
+ SourceRectangle = textureData?.GetSourceRect() ?? data.GetSourceRect();
+ (TintRectangle, TintColor) = GetTinting(item, textureData ?? data);
+ }
+ }
+
+ public ItemActivationResult Activate(
+ Farmer who,
+ DelayedActions delayedActions,
+ 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)
+ {
+ var isActivating = IsActivating();
+ if (!isActivating && (!Context.CanPlayerMove || who.canOnlyWalk || who.UsingTool))
+ {
+ return ItemActivationResult.Ignored;
+ }
+ if (who.CurrentTool != tool)
+ {
+ var toolIndex = who.Items.IndexOf(tool);
+ if (toolIndex < 0)
+ {
+ return ItemActivationResult.Ignored;
+ }
+ who.CurrentToolIndex = toolIndex;
+ }
+ if (tool is FishingRod rod && rod.fishCaught)
+ {
+ rod.doneHoldingFish(who);
+ return ItemActivationResult.Used;
+ }
+ else if (tool is not MeleeWeapon)
+ {
+ who.FireTool();
+ }
+ Game1.pressUseToolButton();
+ return isActivating
+ ? ItemActivationResult.Used
+ : ItemActivationResult.ToolUseStarted;
+ }
+ else if (Item is SObject obj && obj.isPlaceable())
+ {
+ var previousToolIndex = who.CurrentToolIndex;
+ var grabTile = Game1.GetPlacementGrabTile();
+ var placementPosition = Utility.GetNearbyValidPlacementPosition(
+ who,
+ who.currentLocation,
+ obj,
+ (int)grabTile.X * 64,
+ (int)grabTile.Y * 64
+ );
+ try
+ {
+ who.CurrentToolIndex = who.Items.IndexOf(obj);
+ return Utility.tryToPlaceItem(
+ who.currentLocation,
+ obj,
+ (int)placementPosition.X,
+ (int)placementPosition.Y
+ )
+ ? ItemActivationResult.Used
+ : ItemActivationResult.Ignored;
+ }
+ finally
+ {
+ who.CurrentToolIndex = previousToolIndex;
+ }
+ }
+ }
+
+ return FuzzyActivation.ConsumeOrSelect(
+ who,
+ Item,
+ delayedActions,
+ activationType == ItemActivationType.Secondary
+ ? InventoryAction.Select
+ : InventoryAction.Use
+ );
+ }
+
+ public void ContinueActivation()
+ {
+ var who = Game1.player;
+ if (Item is not Tool tool || who.CurrentTool != tool)
+ {
+ return;
+ }
+ if (!who.canReleaseTool || who.Stamina < 1 || tool is FishingRod)
+ {
+ return;
+ }
+ var maxPowerModifier = tool.hasEnchantmentOfType() ? 1 : 0;
+ var maxPower = tool.UpgradeLevel + maxPowerModifier;
+ if (who.toolPower.Value >= maxPower)
+ {
+ return;
+ }
+ if (who.toolHold.Value <= 0)
+ {
+ who.toolHold.Value = (int)(tool.AnimationSpeedModifier * 600);
+ }
+ else
+ {
+ who.toolHold.Value -= Game1.currentGameTime.ElapsedGameTime.Milliseconds;
+ if (who.toolHold.Value <= 0)
+ {
+ who.toolPowerIncrease();
+ }
+ }
+ }
+
+ public bool EndActivation()
+ {
+ var who = Game1.player;
+ if (Item is Tool tool && who.CurrentTool == tool && who.UsingTool && who.canReleaseTool)
+ {
+ if (Item is not FishingRod)
+ {
+ who.EndUsingTool();
+ }
+ return true;
+ }
+ // This isn't equivalent to vanilla logic, but if we detect that the player is no longer
+ // using ANY tool (which is what UsingTool tells us) then any button that the controller is
+ // "holding" should be released anyway, so that it can be pressed again.
+ if (!who.UsingTool)
+ {
+ return true;
+ }
+ return false;
+ }
+
+ public string? GetActivationSound(
+ Farmer who,
+ ItemActivationType activationType,
+ string defaultSound
+ )
+ {
+ return Item is Tool && activationType == ItemActivationType.Instant ? null : defaultSound;
+ }
+
+ private static Texture2D? GetDerivedTexture(Item item)
+ {
+ if (!DerivedTextures.TryGetValue(item, out var texture))
+ {
+ 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
+ {
+ item.drawInMenu(spriteBatch, Vector2.Zero, 1f);
+ texture = renderTarget;
+ DerivedTextures.Add(item, texture);
+ }
+ catch
+ {
+ renderTarget.Dispose();
+ throw;
+ }
+ finally
+ {
+ spriteBatch.End();
+ graphicsDevice.SetRenderTargets(previousTargets);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(
+ $"Error deriving texture for item '{item.Name}' ({item.QualifiedItemId}): {ex}",
+ LogLevel.Error
+ );
+ }
+ }
+ return texture;
+ }
+
+ public bool IsActivating()
+ {
+ return Item is Tool tool && Game1.player.CurrentTool == tool && Game1.player.UsingTool;
+ }
+
+ private static ParsedItemData? GetTextureRedirect(Item item)
+ {
+ return item is SObject obj && item.ItemId == "SmokedFish"
+ ? ItemRegistry.GetData(obj.preservedParentSheetIndex.Value)
+ : null;
+ }
+
+ private static (Rectangle? tintRect, Color? tintColor) GetTinting(
+ Item item,
+ ParsedItemData data
+ )
+ {
+ if (item is not ColoredObject coloredObject)
+ {
+ return default;
+ }
+ if (item.ItemId == "SmokedFish")
+ {
+ // Smoked fish implementation is unique (and private) in ColoredObject.
+ // We don't care about the animation here, but should draw it darkened; the quirky
+ // way this is implemented is to draw a tinted version of the original item sprite
+ // (not an overlay) sprite over top of the original sprite.
+ return (data.GetSourceRect(), new Color(80, 30, 10) * 0.6f);
+ }
+ return !coloredObject.ColorSameIndexAsParentSheetIndex
+ ? (data.GetSourceRect(1), coloredObject.color.Value)
+ : (null, coloredObject.color.Value);
+ }
+
+ // When we call Item.getDescription(), most implementations go through `Game1.parseText`
+ // which splits the string itself onto multiple lines. This tries to remove that, so that we
+ // can do our own wrapping using our own width.
+ //
+ // N.B. The reason we don't just use `ParsedItemData.Description` is that, at least in the
+ // current version, it's often only a "base description" and includes format placeholders,
+ // or is missing suffixes.
+ private static string UnparseText(string text)
+ {
+ var sb = new StringBuilder();
+ var isWhitespace = false;
+ var newlineCount = 0;
+ foreach (var c in text)
+ {
+ if (c == ' ' || c == '\r' || c == '\n')
+ {
+ if (!isWhitespace)
+ {
+ sb.Append(' ');
+ }
+ isWhitespace = true;
+ if (c == '\n')
+ {
+ newlineCount++;
+ }
+ }
+ else
+ {
+ // If the original text has a "paragraph", the formatted text will often look
+ // strange if that is also collapsed into a space. So preserve _multiple_
+ // newlines somewhat as a single "paragraph break".
+ if (newlineCount > 1)
+ {
+ // From implementation above, newlines are counted as whitespace so we know
+ // that the last character will always be a space when hitting here.
+ sb.Length--;
+ sb.Append("\r\n\r\n");
+ }
+ sb.Append(c);
+ isWhitespace = false;
+ newlineCount = 0;
+ }
+ }
+ if (isWhitespace)
+ {
+ sb.Length--;
+ }
+ 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;
+ }
+ }
+}
From 68f08c1fde729dabcfbbeebd58d1ebcea0e4291e Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 03:59:37 -0500
Subject: [PATCH 06/24] Add files via upload
---
StarControl/Menus/QuickSlotRenderer.cs | 1 -
StarControl/Menus/QuickSlotResolver.cs | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs
index c6a04bc..d0bcfcc 100644
--- a/StarControl/Menus/QuickSlotRenderer.cs
+++ b/StarControl/Menus/QuickSlotRenderer.cs
@@ -453,7 +453,6 @@ is Item item
};
}
-
private static Color LumaGray(Color color, float lightness)
{
var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness);
diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs
index 0f3d058..19b3e92 100644
--- a/StarControl/Menus/QuickSlotResolver.cs
+++ b/StarControl/Menus/QuickSlotResolver.cs
@@ -12,7 +12,7 @@ internal class QuickSlotResolver(Farmer player, ModMenu modMenu)
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)
{
From 353d245147ab1a20cfa36f62e9bf0f6900196b25 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 04:01:22 -0500
Subject: [PATCH 07/24] Add files via upload
---
StarControl/Graphics/Sprite.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/StarControl/Graphics/Sprite.cs b/StarControl/Graphics/Sprite.cs
index ac7a3e9..2434948 100644
--- a/StarControl/Graphics/Sprite.cs
+++ b/StarControl/Graphics/Sprite.cs
@@ -23,7 +23,7 @@ public static Sprite ForItemId(string id)
var data = ItemRegistry.GetDataOrErrorItem(id);
return new(data.GetTexture(), data.GetSourceRect());
}
-
+
public static Sprite FromItem(Item item)
{
ArgumentNullException.ThrowIfNull(item);
@@ -95,7 +95,7 @@ public static Sprite FromItem(Item item)
return null;
}
}
-
+
///
/// Attempts to load a sprite from configuration data.
///
From e4e4aafa296b9b4a4f3857bba140fea6649afd41 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 07:13:49 -0500
Subject: [PATCH 08/24] Missed Some Quick Action Menus
---
.../UI/QuickSlotConfigurationViewModel.cs | 264 ++++++++-------
.../UI/QuickSlotPickerItemViewModel.cs | 313 ++++++++++--------
2 files changed, 318 insertions(+), 259 deletions(-)
diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs
index 3c2d6da..7ac418c 100644
--- a/StarControl/UI/QuickSlotConfigurationViewModel.cs
+++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs
@@ -1,112 +1,152 @@
-using System.ComponentModel;
-using PropertyChanged.SourceGenerator;
-using StarControl.Graphics;
-using StardewValley.ItemTypeDefinitions;
-
-namespace StarControl.UI;
-
-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);
-
- public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor;
- public string CurrentAssignmentLabel =>
- IsAssigned
- ? I18n.Config_QuickSlot_Assigned_Title()
- : I18n.Config_QuickSlot_Unassigned_Title();
-
- [DependsOn(nameof(ItemData), nameof(ModAction))]
- 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;
-
- [DependsOn(nameof(ItemData), nameof(ModAction))]
- public TooltipData Tooltip => GetTooltip();
-
- [Notify]
- private ParsedItemData? itemData;
-
- [Notify]
- private ModMenuItemConfigurationViewModel? modAction;
-
- [Notify]
- private bool requireConfirmation;
-
- [Notify]
- private bool useSecondaryAction;
-
- public void Clear()
- {
- ItemData = null;
- ModAction = null;
- UseSecondaryAction = false;
- }
-
- private Sprite? GetIcon()
- {
- return ItemData is not null
- ? new(ItemData.GetTexture(), ItemData.GetSourceRect())
- : ModAction?.Icon;
- }
-
- private TooltipData GetTooltip()
- {
- if (ItemData is not null)
- {
- return new(
- Title: ItemData.DisplayName,
- Text: ItemData.Description,
- Item: ItemRegistry.Create(ItemData.QualifiedItemId)
- );
- }
- 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());
- }
-
- private void ModAction_PropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- if (e.PropertyName == nameof(ModAction.Icon))
- {
- OnPropertyChanged(new(nameof(Icon)));
- }
- else if (e.PropertyName is nameof(ModAction.Name) or nameof(ModAction.Description))
- {
- OnPropertyChanged(new(nameof(Tooltip)));
- }
- }
-
- private void OnItemDataChanged()
- {
- if (ItemData is not null)
- {
- ModAction = null;
- }
- }
-
- private void OnModActionChanged(
- ModMenuItemConfigurationViewModel? oldValue,
- ModMenuItemConfigurationViewModel? newValue
- )
- {
- if (oldValue is not null)
- {
- oldValue.PropertyChanged -= ModAction_PropertyChanged;
- }
- if (newValue is not null)
- {
- ItemData = null;
- newValue.PropertyChanged += ModAction_PropertyChanged;
- }
- }
-}
+using System.ComponentModel;
+using System.Linq;
+using PropertyChanged.SourceGenerator;
+using StarControl.Graphics;
+using StardewValley;
+using StardewValley.ItemTypeDefinitions;
+
+namespace StarControl.UI;
+
+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);
+
+ public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor;
+ public string CurrentAssignmentLabel =>
+ IsAssigned
+ ? I18n.Config_QuickSlot_Assigned_Title()
+ : I18n.Config_QuickSlot_Unassigned_Title();
+
+ [DependsOn(nameof(ItemData), nameof(ModAction))]
+ 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;
+
+ [DependsOn(nameof(ItemData), nameof(ModAction))]
+ public TooltipData Tooltip => GetTooltip();
+
+ [Notify]
+ private ParsedItemData? itemData;
+
+ [Notify]
+ private ModMenuItemConfigurationViewModel? modAction;
+
+ [Notify]
+ private bool requireConfirmation;
+
+ [Notify]
+ private bool useSecondaryAction;
+
+ public void Clear()
+ {
+ ItemData = null;
+ ModAction = null;
+ UseSecondaryAction = false;
+ }
+
+ private Sprite? GetIcon()
+ {
+ if (ItemData is not null)
+ {
+ // Weird-item-proofing: Item Bags and similar mods don't register ItemRegistry data.
+ // If this is an error item, try to find the real inventory item and render it via drawInMenu.
+ if (ItemData.IsErrorItem)
+ {
+ var invItem = Game1.player?.Items?.FirstOrDefault(i =>
+ i is not null && i.QualifiedItemId == ItemData.QualifiedItemId
+ );
+
+ if (invItem is not null)
+ {
+ return Sprite.FromItem(invItem);
+ }
+ }
+
+ // Normal path for registered items
+ return new(ItemData.GetTexture(), ItemData.GetSourceRect());
+ }
+
+ return ModAction?.Icon;
+ }
+
+ private TooltipData GetTooltip()
+ {
+ if (ItemData is not null)
+ {
+ // Weird-item-proofing: if ItemData is an error item, use the real inventory item instead.
+ if (ItemData.IsErrorItem)
+ {
+ var invItem = Game1.player?.Items?.FirstOrDefault(i =>
+ i is not null && i.QualifiedItemId == ItemData.QualifiedItemId
+ );
+
+ if (invItem is not null)
+ {
+ return new(
+ Title: invItem.DisplayName,
+ Text: invItem.getDescription(),
+ Item: invItem
+ );
+ }
+ }
+
+ // Normal path for registered items
+ return new(
+ Title: ItemData.DisplayName,
+ Text: ItemData.Description,
+ Item: ItemRegistry.Create(ItemData.QualifiedItemId)
+ );
+ }
+
+ 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());
+ }
+
+ private void ModAction_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ModAction.Icon))
+ {
+ OnPropertyChanged(new(nameof(Icon)));
+ }
+ else if (e.PropertyName is nameof(ModAction.Name) or nameof(ModAction.Description))
+ {
+ OnPropertyChanged(new(nameof(Tooltip)));
+ }
+ }
+
+ private void OnItemDataChanged()
+ {
+ if (ItemData is not null)
+ {
+ ModAction = null;
+ }
+ }
+
+ private void OnModActionChanged(
+ ModMenuItemConfigurationViewModel? oldValue,
+ ModMenuItemConfigurationViewModel? newValue
+ )
+ {
+ if (oldValue is not null)
+ {
+ oldValue.PropertyChanged -= ModAction_PropertyChanged;
+ }
+ if (newValue is not null)
+ {
+ ItemData = null;
+ newValue.PropertyChanged += ModAction_PropertyChanged;
+ }
+ }
+}
diff --git a/StarControl/UI/QuickSlotPickerItemViewModel.cs b/StarControl/UI/QuickSlotPickerItemViewModel.cs
index bb5f34f..3eb9fcb 100644
--- a/StarControl/UI/QuickSlotPickerItemViewModel.cs
+++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs
@@ -1,147 +1,166 @@
-using Microsoft.Xna.Framework.Graphics;
-using StarControl.Graphics;
-using StardewValley.ItemTypeDefinitions;
-using StardewValley.Objects;
-
-namespace StarControl.UI;
-
-///
-/// View model for an item displayed in the quick-slot picker, supporting vanilla items and mod actions.
-///
-///
-///
-/// Item images require a specialized context because they are not always simple textures. In many
-/// cases, the item must be drawn with a tint or overlay, which is actually a second sprite.
-///
-///
-/// Delegate to update a quick slot to reference this item.
-/// The texture where the item image is located; both the base image and
-/// the overlay (if applicable) are expected to be found in this texture.
-/// The region of the where the base image
-/// for the item is located.
-/// The region of the where the tint or
-/// overlay image for the item is located.
-/// The tint color, which either applies to the image in the
-/// , if one is specified, or the base image (in
-/// ) if no is specified.
-internal class QuickSlotPickerItemViewModel(
- Action update,
- Texture2D sourceTexture,
- Rectangle? sourceRect,
- Rectangle? tintRect = null,
- Color? tintColor = null,
- TooltipData? tooltip = null
-)
-{
- ///
- /// Whether the image uses a separate .
- ///
- public bool HasTintSprite => TintSprite is not null;
-
- ///
- /// Sprite data for the base image.
- ///
- public Sprite? Sprite { get; } = new(sourceTexture, sourceRect ?? sourceTexture.Bounds);
-
- ///
- /// Tint color for the .
- ///
- public Color SpriteColor { get; } = (!tintRect.HasValue ? tintColor : null) ?? Color.White;
-
- ///
- /// Sprite data for the overlay image, if any.
- ///
- public Sprite? TintSprite { get; } =
- tintRect.HasValue ? new(sourceTexture, tintRect.Value) : null;
-
- ///
- /// Tint color for the .
- ///
- public Color TintSpriteColor { get; } = tintColor ?? Color.White;
-
- public TooltipData? Tooltip { get; } = tooltip;
-
- private static readonly Color SmokedFishTintColor = new Color(80, 30, 10) * 0.6f;
-
- ///
- /// Creates a new instance using the game data for a known item.
- ///
- /// The item to display.
- public static QuickSlotPickerItemViewModel ForItem(Item item)
- {
- 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,
- fishData.GetTexture(),
- fishData.GetSourceRect(),
- fishData.GetSourceRect(),
- SmokedFishTintColor
- );
- }
- Color? tintColor = null;
- Rectangle? tintRect = null;
- if (item is ColoredObject co)
- {
- tintColor = co.color.Value;
- if (!co.ColorSameIndexAsParentSheetIndex)
- {
- 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(),
- data.GetTexture(),
- data.GetSourceRect(),
- tintRect,
- tintColor,
- tooltip
- );
- }
-
- ///
- /// Creates a new instance using the base data for an in-game item.
- ///
- /// The item data.
- public static QuickSlotPickerItemViewModel ForItemData(ParsedItemData data)
- {
- TooltipData tooltip = !string.IsNullOrEmpty(data.Description)
- ? new(Title: data.DisplayName, Text: data.Description)
- : new(data.DisplayName);
- return new(
- slot => slot.ItemData = data.GetBaseItem(),
- data.GetTexture(),
- data.GetSourceRect(),
- tooltip: tooltip
- );
- }
-
- ///
- /// Creates a new instance using the configuration for a mod action.
- ///
- /// The mod action.
- public static QuickSlotPickerItemViewModel ForModAction(ModMenuItemConfigurationViewModel item)
- {
- var icon = item.Icon;
- return new(
- slot => slot.ModAction = item,
- icon.Texture,
- icon.SourceRect,
- tooltip: item.Tooltip
- );
- }
-
- ///
- /// Updates a quick slot to point to this item.
- ///
- /// The quick slot to be updated.
- public void UpdateSlot(QuickSlotConfigurationViewModel slot)
- {
- update(slot);
- }
-}
+using Microsoft.Xna.Framework.Graphics;
+using StarControl.Graphics;
+using StardewValley;
+using StardewValley.ItemTypeDefinitions;
+using StardewValley.Objects;
+
+namespace StarControl.UI;
+
+///
+/// View model for an item displayed in the quick-slot picker, supporting vanilla items and mod actions.
+///
+///
+///
+/// Item images require a specialized context because they are not always simple textures. In many
+/// cases, the item must be drawn with a tint or overlay, which is actually a second sprite.
+///
+///
+/// Delegate to update a quick slot to reference this item.
+/// The texture where the item image is located; both the base image and
+/// the overlay (if applicable) are expected to be found in this texture.
+/// The region of the where the base image
+/// for the item is located.
+/// The region of the where the tint or
+/// overlay image for the item is located.
+/// The tint color, which either applies to the image in the
+/// , if one is specified, or the base image (in
+/// ) if no is specified.
+internal class QuickSlotPickerItemViewModel(
+ Action update,
+ Texture2D sourceTexture,
+ Rectangle? sourceRect,
+ Rectangle? tintRect = null,
+ Color? tintColor = null,
+ TooltipData? tooltip = null
+)
+{
+ ///
+ /// Whether the image uses a separate .
+ ///
+ public bool HasTintSprite => TintSprite is not null;
+
+ ///
+ /// Sprite data for the base image.
+ ///
+ public Sprite? Sprite { get; } = new(sourceTexture, sourceRect ?? sourceTexture.Bounds);
+
+ ///
+ /// Tint color for the .
+ ///
+ public Color SpriteColor { get; } = (!tintRect.HasValue ? tintColor : null) ?? Color.White;
+
+ ///
+ /// Sprite data for the overlay image, if any.
+ ///
+ public Sprite? TintSprite { get; } =
+ tintRect.HasValue ? new(sourceTexture, tintRect.Value) : null;
+
+ ///
+ /// Tint color for the .
+ ///
+ public Color TintSpriteColor { get; } = tintColor ?? Color.White;
+
+ public TooltipData? Tooltip { get; } = tooltip;
+
+ private static readonly Color SmokedFishTintColor = new Color(80, 30, 10) * 0.6f;
+
+ ///
+ /// Creates a new instance using the game data for a known item.
+ ///
+ /// 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,
+ 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.GetBaseItem(),
+ sprite.Texture,
+ sprite.SourceRect,
+ tooltip: tooltip
+ );
+ }
+
+ // Normal (registered) items keep tint/overlay behavior
+ Color? tintColor = null;
+ Rectangle? tintRect = null;
+ if (item is ColoredObject co)
+ {
+ tintColor = co.color.Value;
+ if (!co.ColorSameIndexAsParentSheetIndex)
+ {
+ tintRect = data.GetSourceRect(1);
+ }
+ }
+
+ return new(
+ slot => slot.ItemData = data.GetBaseItem(),
+ data.GetTexture(),
+ data.GetSourceRect(),
+ tintRect,
+ tintColor,
+ tooltip
+ );
+ }
+
+ ///
+ /// Creates a new instance using the base data for an in-game item.
+ ///
+ /// The item data.
+ public static QuickSlotPickerItemViewModel ForItemData(ParsedItemData data)
+ {
+ TooltipData tooltip = !string.IsNullOrEmpty(data.Description)
+ ? new(Title: data.DisplayName, Text: data.Description)
+ : new(data.DisplayName);
+ return new(
+ slot => slot.ItemData = data.GetBaseItem(),
+ data.GetTexture(),
+ data.GetSourceRect(),
+ tooltip: tooltip
+ );
+ }
+
+ ///
+ /// Creates a new instance using the configuration for a mod action.
+ ///
+ /// The mod action.
+ public static QuickSlotPickerItemViewModel ForModAction(ModMenuItemConfigurationViewModel item)
+ {
+ var icon = item.Icon;
+ return new(
+ slot => slot.ModAction = item,
+ icon.Texture,
+ icon.SourceRect,
+ tooltip: item.Tooltip
+ );
+ }
+
+ ///
+ /// Updates a quick slot to point to this item.
+ ///
+ /// The quick slot to be updated.
+ public void UpdateSlot(QuickSlotConfigurationViewModel slot)
+ {
+ update(slot);
+ }
+}
From f8a6621cd206d02f5bd270d90b54308b6de0c882 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 17:04:27 -0500
Subject: [PATCH 09/24] Add files via upload
---
StarControl/IItemLookup.cs | 38 ++++++++++++++++++++------------------
1 file changed, 20 insertions(+), 18 deletions(-)
diff --git a/StarControl/IItemLookup.cs b/StarControl/IItemLookup.cs
index b8dd58b..a907f99 100644
--- a/StarControl/IItemLookup.cs
+++ b/StarControl/IItemLookup.cs
@@ -1,18 +1,20 @@
-namespace StarControl;
-
-///
-/// Provides the fields required to look up a menu item in any configured menu.
-///
-public interface IItemLookup
-{
- ///
- /// Identifies an item to select or use. Can be the ID of a regular game item or of a Mod Menu
- /// action, depending on the .
- ///
- string Id { get; }
-
- ///
- /// The type of ID that the refers to.
- ///
- ItemIdType IdType { get; }
-}
+namespace StarControl;
+
+///
+/// Provides the fields required to look up a menu item in any configured menu.
+///
+public interface IItemLookup
+{
+ ///
+ /// Identifies an item to select or use. Can be the ID of a regular game item or of a Mod Menu
+ /// action, depending on the .
+ ///
+ string Id { get; }
+
+ string? SubId { get; }
+
+ ///
+ /// The type of ID that the refers to.
+ ///
+ ItemIdType IdType { get; }
+}
From 6e94a34f7f8fe20de19a5b06392f512df4df87ea Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 17:05:15 -0500
Subject: [PATCH 10/24] Add files via upload
---
StarControl/Config/QuickSlotConfiguration.cs | 3 +++
1 file changed, 3 insertions(+)
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;
}
From eea22238ee22e2df129abd2503d85643980acd75 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 17:05:58 -0500
Subject: [PATCH 11/24] Add files via upload
---
StarControl/Data/RemappingSlot.cs | 2 ++
1 file changed, 2 insertions(+)
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; }
}
From ce11e7c9e3e363b51c9bb6526295618d0e24dddd Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 17:06:54 -0500
Subject: [PATCH 12/24] Add files via upload
---
StarControl/Menus/QuickSlotController.cs | 2 +-
StarControl/Menus/QuickSlotRenderer.cs | 1065 +++++++++++-----------
StarControl/Menus/QuickSlotResolver.cs | 293 +++---
StarControl/Menus/RemappingController.cs | 2 +-
4 files changed, 698 insertions(+), 664 deletions(-)
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 d0bcfcc..a12ec00 100644
--- a/StarControl/Menus/QuickSlotRenderer.cs
+++ b/StarControl/Menus/QuickSlotRenderer.cs
@@ -1,529 +1,536 @@
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Graphics;
-using StarControl.Config;
-using StarControl.Data;
-using StarControl.Graphics;
-using StardewValley;
-
-namespace StarControl.Menus;
-
-internal class QuickSlotRenderer(GraphicsDevice graphicsDevice, ModConfig config)
-{
- private const float MENU_SCALE = 0.7f; // scales the entire quick action menu
- private const float SLOT_CONTENT_SCALE = 1.2f; // scales slot visuals without moving the menu
-
- private record ButtonFlash(FlashType Type, float DurationMs, float ElapsedMs = 0);
-
- private enum FlashType
- {
- Delay,
- Error,
- }
-
- private enum PromptPosition
- {
- Above,
- Below,
- Left,
- Right,
- }
-
- public float BackgroundOpacity { get; set; } = 1;
- public float SpriteOpacity { get; set; } = 1;
-
- public IReadOnlyDictionary SlotItems { get; set; } =
- new Dictionary();
- public IReadOnlyDictionary Slots { get; set; } =
- new Dictionary();
-
- public bool UnassignedButtonsVisible { get; set; } = true;
-
- private const int IMAGE_SIZE = 64;
- private const int SLOT_PADDING = 20;
- private const int SLOT_SIZE = (int)((IMAGE_SIZE + SLOT_PADDING * 2) * MENU_SCALE * 1f);
- private const int SLOT_DISTANCE = (int)((SLOT_SIZE - 12) * MENU_SCALE * 2.6f);
- private const int MARGIN_OUTER = 32;
- private const int MARGIN_HORIZONTAL = 120;
- private const int MARGIN_VERTICAL = 16;
- private const int PROMPT_OFFSET = SLOT_SIZE / 2;
- private const int PROMPT_SIZE = 26;
- private const int BACKGROUND_RADIUS = (int)(
- (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 = [];
- private readonly GraphicsDevice graphicsDevice = graphicsDevice;
-
- private Color disabledBackgroundColor = Color.Transparent;
- private Color innerBackgroundColor = Color.Transparent;
- private bool isDirty = true;
- private float quickSlotScale = 1f;
- private Texture2D outerBackground = null!;
- private Texture2D slotBackground = null!;
- private Texture2D? uiTexture;
- private ButtonIconSet? uiTextureIconSet;
-
- public void Draw(SpriteBatch b, Rectangle viewport)
- {
- UpdateScale();
- if (isDirty)
- {
- innerBackgroundColor = (Color)config.Style.OuterBackgroundColor * 0.6f;
- disabledBackgroundColor = LumaGray(innerBackgroundColor, 0.75f);
- RefreshSlots();
- }
-
- var leftOrigin = new Point(
- viewport.Left
- + Scale(MARGIN_HORIZONTAL * MENU_SCALE)
- + Scale(MARGIN_OUTER * MENU_SCALE)
- + Scale(SLOT_SIZE / 2f),
- viewport.Bottom
- - Scale(MARGIN_VERTICAL * MENU_SCALE)
- - Scale(MARGIN_OUTER * MENU_SCALE)
- - Scale(SLOT_SIZE)
- - Scale(SLOT_SIZE / 2f)
- );
- var leftShoulderPosition = leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE));
- if (
- UnassignedButtonsVisible
- || HasSlotSprite(SButton.DPadLeft)
- || HasSlotSprite(SButton.DPadUp)
- || HasSlotSprite(SButton.DPadRight)
- || HasSlotSprite(SButton.DPadDown)
- )
- {
- var leftBackgroundRect = GetCircleRect(
- leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)),
- Scale(BACKGROUND_RADIUS)
- );
- b.Draw(outerBackground, leftBackgroundRect, OuterBackgroundColor * BackgroundOpacity);
- DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left);
- DrawSlot(
- b,
- leftOrigin.Add(
- Scale(SLOT_DISTANCE * MENU_SCALE),
- -Scale(SLOT_DISTANCE * MENU_SCALE)
- ),
- SButton.DPadUp,
- PromptPosition.Above
- );
- DrawSlot(
- b,
- leftOrigin.Add(
- Scale(SLOT_DISTANCE * MENU_SCALE),
- Scale(SLOT_DISTANCE * MENU_SCALE)
- ),
- SButton.DPadDown,
- PromptPosition.Below
- );
- DrawSlot(
- b,
- leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE * 2f)),
- SButton.DPadRight,
- PromptPosition.Right
- );
- leftShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE);
- }
- if (enabledSlots.Contains(SButton.LeftShoulder))
- {
- DrawSlot(
- b,
- leftShoulderPosition,
- SButton.LeftShoulder,
- PromptPosition.Above,
- darken: true
- );
- }
-
- var rightOrigin = new Point(
- viewport.Right
- - Scale(MARGIN_HORIZONTAL * MENU_SCALE)
- - Scale(MARGIN_OUTER * MENU_SCALE)
- - Scale(SLOT_SIZE / 2f),
- leftOrigin.Y
- );
- var rightShoulderPosition = rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE));
- if (
- UnassignedButtonsVisible
- || HasSlotSprite(SButton.ControllerX)
- || HasSlotSprite(SButton.ControllerY)
- || HasSlotSprite(SButton.ControllerA)
- || HasSlotSprite(SButton.ControllerB)
- )
- {
- var rightBackgroundRect = GetCircleRect(
- rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)),
- Scale(BACKGROUND_RADIUS)
- );
- b.Draw(outerBackground, rightBackgroundRect, OuterBackgroundColor * BackgroundOpacity);
- DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right);
- DrawSlot(
- b,
- rightOrigin.Add(
- -Scale(SLOT_DISTANCE * MENU_SCALE),
- -Scale(SLOT_DISTANCE * MENU_SCALE)
- ),
- SButton.ControllerY,
- PromptPosition.Above
- );
- DrawSlot(
- b,
- rightOrigin.Add(
- -Scale(SLOT_DISTANCE * MENU_SCALE),
- Scale(SLOT_DISTANCE * MENU_SCALE)
- ),
- SButton.ControllerA,
- PromptPosition.Below
- );
- DrawSlot(
- b,
- rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE * 2f)),
- SButton.ControllerX,
- PromptPosition.Left
- );
- rightShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE);
- }
- if (enabledSlots.Contains(SButton.RightShoulder))
- {
- DrawSlot(
- b,
- rightShoulderPosition,
- SButton.RightShoulder,
- PromptPosition.Above,
- darken: true
- );
- }
- }
-
- private void UpdateScale(bool force = false)
- {
- var desiredScale = Math.Clamp(config.Style.QuickActionScale, 0.5f, 1.5f);
- if (
- !force
- && MathF.Abs(desiredScale - quickSlotScale) < 0.001f
- && outerBackground is not null
- )
- {
- return;
- }
- quickSlotScale = desiredScale;
- outerBackground?.Dispose();
- slotBackground?.Dispose();
- outerBackground = ShapeTexture.CreateCircle(
- Scale((SLOT_SIZE + SLOT_SIZE / 2f + MARGIN_OUTER) * MENU_SCALE),
- filled: true,
- graphicsDevice: graphicsDevice
- );
- slotBackground = ShapeTexture.CreateCircle(
- Scale(SLOT_SIZE / 2f),
- filled: true,
- graphicsDevice: graphicsDevice
- );
- isDirty = true;
- }
-
- private int Scale(float value)
- {
- return (int)MathF.Round(value * quickSlotScale);
- }
-
- public void FlashDelay(SButton button)
- {
- flashes[button] = new(FlashType.Delay, config.Input.ActivationDelayMs);
- }
-
- public void FlashError(SButton button)
- {
- flashes[button] = new(FlashType.Error, Animation.ERROR_FLASH_DURATION_MS);
- }
-
- public bool HasSlotSprite(SButton button)
- {
- return slotSprites.ContainsKey(button);
- }
-
- public void Invalidate()
- {
- isDirty = true;
- }
-
- public void Update(TimeSpan elapsed)
- {
- foreach (var (button, flash) in flashes)
- {
- var flashElapsedMs = flash.ElapsedMs + (float)elapsed.TotalMilliseconds;
- if (flashElapsedMs >= flash.DurationMs)
- {
- flashes.Remove(button);
- continue;
- }
- flashes[button] = flash with { ElapsedMs = flashElapsedMs };
- }
- }
-
- private void DrawSlot(
- SpriteBatch b,
- Point origin,
- SButton button,
- PromptPosition promptPosition,
- bool darken = false
- )
- {
- var isAssigned = slotSprites.TryGetValue(button, out var sprite);
- if (!isAssigned && !UnassignedButtonsVisible)
- {
- return;
- }
-
- var isEnabled = enabledSlots.Contains(button);
- var backgroundRect = GetCircleRect(origin, Scale(SLOT_SIZE * SLOT_CONTENT_SCALE / 2f));
- if (darken)
- {
- var darkenRect = backgroundRect;
- darkenRect.Inflate(4, 4);
- b.Draw(slotBackground, darkenRect, Color.Black * BackgroundOpacity);
- }
- var backgroundColor = GetBackgroundColor(button, isAssigned && isEnabled);
- b.Draw(slotBackground, backgroundRect, backgroundColor * BackgroundOpacity);
-
- var slotOpacity = isEnabled ? 1f : 0.5f;
-
- if (isAssigned)
- {
- var spriteRect = GetCircleRect(
- origin,
- Scale(IMAGE_SIZE * MENU_SCALE * SLOT_CONTENT_SCALE / 2f)
- );
- if (SlotItems.TryGetValue(button, out var item) && item.Texture is not null)
- {
- ItemRenderer.Draw(
- b,
- item,
- spriteRect,
- config.Style,
- opacity: slotOpacity * SpriteOpacity
- );
- }
- else
- {
- b.Draw(
- sprite!.Texture,
- spriteRect,
- sprite.SourceRect,
- Color.White * slotOpacity * SpriteOpacity
- );
- }
- }
-
- if (GetPromptSprite(button) is { } promptSprite)
- {
- var promptOffset = Scale(PROMPT_OFFSET * SLOT_CONTENT_SCALE);
- var promptOrigin = promptPosition switch
- {
- PromptPosition.Above => origin.AddY(-promptOffset),
- PromptPosition.Below => origin.AddY(promptOffset),
- PromptPosition.Left => origin.AddX(-promptOffset),
- PromptPosition.Right => origin.AddX(promptOffset),
- _ => throw new ArgumentException(
- $"Invalid prompt position: {promptPosition}",
- nameof(promptPosition)
- ),
- };
- var promptRect = GetCircleRect(
- promptOrigin,
- Scale(PROMPT_SIZE * SLOT_CONTENT_SCALE / 2f)
- );
- b.Draw(
- promptSprite.Texture,
- promptRect,
- promptSprite.SourceRect,
- Color.White * slotOpacity * SpriteOpacity
- );
- }
- }
-
- private Color GetBackgroundColor(SButton button, bool enabled)
- {
- var baseColor = enabled ? innerBackgroundColor : disabledBackgroundColor;
- if (!flashes.TryGetValue(button, out var flash))
- {
- return baseColor;
- }
- var (flashColor, position) = flash.Type switch
- {
- FlashType.Delay => (
- config.Style.HighlightColor,
- Animation.GetDelayFlashPosition(flash.ElapsedMs)
- ),
- FlashType.Error => (Color.Red, Animation.GetErrorFlashPosition(flash.ElapsedMs)),
- _ => (Color.White, 0),
- };
- return Color.Lerp(baseColor, flashColor, position);
- }
-
- private static Rectangle GetCircleRect(Point center, int radius)
- {
- int length = radius * 2;
- return new(center.X - radius, center.Y - radius, length, length);
- }
-
- private static Sprite GetIconSprite(IconConfig icon)
- {
- return !string.IsNullOrEmpty(icon.ItemId)
- ? Sprite.ForItemId(icon.ItemId)
- : Sprite.TryLoad(icon.TextureAssetPath, icon.SourceRect)
- ?? Sprite.ForItemId("Error_Invalid");
- }
-
- private Sprite? GetModItemSprite(string id)
- {
- var itemConfig = config
- .Items.ModMenuPages.SelectMany(items => items)
- .FirstOrDefault(item => item.Id == id);
- return itemConfig?.Icon is { } icon ? GetIconSprite(icon) : null;
- }
-
- private Sprite? GetPromptSprite(SButton button)
- {
- var (rowIndex, columnIndex) = button switch
- {
- SButton.DPadUp => (1, 0),
- SButton.DPadRight => (1, 1),
- SButton.DPadDown => (1, 2),
- SButton.DPadLeft => (1, 3),
- SButton.ControllerA => (1, 4),
- SButton.ControllerB => (1, 5),
- SButton.ControllerX => (1, 6),
- SButton.ControllerY => (1, 7),
- SButton.LeftTrigger => (2, 0),
- SButton.RightTrigger => (2, 1),
- SButton.LeftShoulder => (2, 2),
- SButton.RightShoulder => (2, 3),
- SButton.ControllerBack => (2, 4),
- SButton.ControllerStart => (2, 5),
- SButton.LeftStick => (2, 6),
- SButton.RightStick => (2, 7),
- _ => (-1, -1),
- };
- if (columnIndex == -1)
- {
- return null;
- }
- var texture = GetUiTexture();
- return new(texture, new(columnIndex * 16, rowIndex * 16, 16, 16));
- }
-
- private Texture2D GetUiTexture()
- {
- var desiredSet = config.Style.ButtonIconSet;
- if (uiTexture is null || uiTextureIconSet != desiredSet)
- {
- var assetPath =
- desiredSet == ButtonIconSet.PlayStation
- ? Sprites.UI_PLAYSTATION_TEXTURE_PATH
- : Sprites.UI_TEXTURE_PATH;
- uiTexture = Game1.content.Load(assetPath);
- uiTextureIconSet = desiredSet;
- }
- return uiTexture;
- }
-
- private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection
- inventoryItems)
- {
- if (string.IsNullOrWhiteSpace(itemLookup.Id))
- return null;
-
- return itemLookup.IdType switch
- {
- ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem(
- itemLookup.Id,
- inventoryItems
- )
- is Item item
- ? Sprite.FromItem(item) // handles Item Bags via drawInMenu fallback
- : Sprite.ForItemId(itemLookup.Id), // fallback for normal items
-
- ItemIdType.ModItem => GetModItemSprite(itemLookup.Id),
- _ => null,
- };
- }
-
- private static Color LumaGray(Color color, float lightness)
- {
- var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness);
- return new(v, v, v);
- }
-
- private void RefreshSlots()
- {
- Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data.");
- enabledSlots.Clear();
- slotSprites.Clear();
- foreach (var (button, slotConfig) in Slots)
- {
- Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}...");
- Sprite? sprite = null;
- if (SlotItems.TryGetValue(button, out var item))
- {
- if (item.Texture is not null)
- {
- Logger.Log(
- LogCategory.QuickSlots,
- $"Using configured item sprite for {item.Title} in {button} slot."
- );
- sprite = new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds);
- }
- else
- {
- Logger.Log(
- LogCategory.QuickSlots,
- $"Item {item.Title} in {button} slot has no texture; using default sprite."
- );
- }
- enabledSlots.Add(button);
- Logger.Log(
- LogCategory.QuickSlots,
- $"Enabled {button} slot with '{item.Title}'.",
- LogLevel.Info
- );
- }
- else
- {
- Logger.Log(
- LogCategory.QuickSlots,
- $"Disabled unassigned {button} slot.",
- LogLevel.Info
- );
- }
- sprite ??= GetSlotSprite(slotConfig, Game1.player.Items);
- if (sprite is not null)
- {
- slotSprites.Add(button, sprite);
- }
- }
- isDirty = false;
- }
-}
-
-file static class PointExtensions
-{
- public static Point Add(this Point point, int x, int y)
- {
- return new(point.X + x, point.Y + y);
- }
-
- public static Point AddX(this Point point, int x)
- {
- return new(point.X + x, point.Y);
- }
-
- public static Point AddY(this Point point, int y)
- {
- return new(point.X, point.Y + y);
- }
-}
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using StarControl.Config;
+using StarControl.Data;
+using StarControl.Graphics;
+using StardewValley;
+
+namespace StarControl.Menus;
+
+internal class QuickSlotRenderer(GraphicsDevice graphicsDevice, ModConfig config)
+{
+ private const float MENU_SCALE = 0.7f; // scales the entire quick action menu
+ private const float SLOT_CONTENT_SCALE = 1.2f; // scales slot visuals without moving the menu
+
+ private record ButtonFlash(FlashType Type, float DurationMs, float ElapsedMs = 0);
+
+ private enum FlashType
+ {
+ Delay,
+ Error,
+ }
+
+ private enum PromptPosition
+ {
+ Above,
+ Below,
+ Left,
+ Right,
+ }
+
+ public float BackgroundOpacity { get; set; } = 1;
+ public float SpriteOpacity { get; set; } = 1;
+
+ public IReadOnlyDictionary SlotItems { get; set; } =
+ new Dictionary();
+ public IReadOnlyDictionary Slots { get; set; } =
+ new Dictionary();
+
+ public bool UnassignedButtonsVisible { get; set; } = true;
+
+ private const int IMAGE_SIZE = 64;
+ private const int SLOT_PADDING = 20;
+ private const int SLOT_SIZE = (int)((IMAGE_SIZE + SLOT_PADDING * 2) * MENU_SCALE * 1f);
+ private const int SLOT_DISTANCE = (int)((SLOT_SIZE - 12) * MENU_SCALE * 2.6f);
+ private const int MARGIN_OUTER = 32;
+ private const int MARGIN_HORIZONTAL = 120;
+ private const int MARGIN_VERTICAL = 16;
+ private const int PROMPT_OFFSET = SLOT_SIZE / 2;
+ private const int PROMPT_SIZE = 26;
+ private const int BACKGROUND_RADIUS = (int)(
+ (SLOT_DISTANCE + SLOT_SIZE / 2 + MARGIN_OUTER) * 0.7f
+ );
+
+ private readonly Dictionary flashes = [];
+ private readonly HashSet enabledSlots = [];
+ private readonly Dictionary slotSprites = [];
+ private readonly GraphicsDevice graphicsDevice = graphicsDevice;
+
+ private Color disabledBackgroundColor = Color.Transparent;
+ private Color innerBackgroundColor = Color.Transparent;
+ private bool isDirty = true;
+ private float quickSlotScale = 1f;
+ private Texture2D outerBackground = null!;
+ private Texture2D slotBackground = null!;
+ private Texture2D? uiTexture;
+ private ButtonIconSet? uiTextureIconSet;
+
+ public void Draw(SpriteBatch b, Rectangle viewport)
+ {
+ UpdateScale();
+ if (isDirty)
+ {
+ innerBackgroundColor = (Color)config.Style.OuterBackgroundColor * 0.6f;
+ disabledBackgroundColor = LumaGray(innerBackgroundColor, 0.75f);
+ RefreshSlots();
+ }
+
+ var leftOrigin = new Point(
+ viewport.Left
+ + Scale(MARGIN_HORIZONTAL * MENU_SCALE)
+ + Scale(MARGIN_OUTER * MENU_SCALE)
+ + Scale(SLOT_SIZE / 2f),
+ viewport.Bottom
+ - Scale(MARGIN_VERTICAL * MENU_SCALE)
+ - Scale(MARGIN_OUTER * MENU_SCALE)
+ - Scale(SLOT_SIZE)
+ - Scale(SLOT_SIZE / 2f)
+ );
+ var leftShoulderPosition = leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE));
+ if (
+ UnassignedButtonsVisible
+ || HasSlotSprite(SButton.DPadLeft)
+ || HasSlotSprite(SButton.DPadUp)
+ || HasSlotSprite(SButton.DPadRight)
+ || HasSlotSprite(SButton.DPadDown)
+ )
+ {
+ var leftBackgroundRect = GetCircleRect(
+ leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)),
+ Scale(BACKGROUND_RADIUS)
+ );
+ b.Draw(
+ outerBackground,
+ leftBackgroundRect,
+ (Color)config.Style.InnerBackgroundColor * BackgroundOpacity
+ );
+ DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left);
+ DrawSlot(
+ b,
+ leftOrigin.Add(
+ Scale(SLOT_DISTANCE * MENU_SCALE),
+ -Scale(SLOT_DISTANCE * MENU_SCALE)
+ ),
+ SButton.DPadUp,
+ PromptPosition.Above
+ );
+ DrawSlot(
+ b,
+ leftOrigin.Add(
+ Scale(SLOT_DISTANCE * MENU_SCALE),
+ Scale(SLOT_DISTANCE * MENU_SCALE)
+ ),
+ SButton.DPadDown,
+ PromptPosition.Below
+ );
+ DrawSlot(
+ b,
+ leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE * 2f)),
+ SButton.DPadRight,
+ PromptPosition.Right
+ );
+ leftShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE);
+ }
+ if (enabledSlots.Contains(SButton.LeftShoulder))
+ {
+ DrawSlot(
+ b,
+ leftShoulderPosition,
+ SButton.LeftShoulder,
+ PromptPosition.Above,
+ darken: true
+ );
+ }
+
+ var rightOrigin = new Point(
+ viewport.Right
+ - Scale(MARGIN_HORIZONTAL * MENU_SCALE)
+ - Scale(MARGIN_OUTER * MENU_SCALE)
+ - Scale(SLOT_SIZE / 2f),
+ leftOrigin.Y
+ );
+ var rightShoulderPosition = rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE));
+ if (
+ UnassignedButtonsVisible
+ || HasSlotSprite(SButton.ControllerX)
+ || HasSlotSprite(SButton.ControllerY)
+ || HasSlotSprite(SButton.ControllerA)
+ || HasSlotSprite(SButton.ControllerB)
+ )
+ {
+ var rightBackgroundRect = GetCircleRect(
+ rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)),
+ Scale(BACKGROUND_RADIUS)
+ );
+ b.Draw(
+ outerBackground,
+ rightBackgroundRect,
+ (Color)config.Style.InnerBackgroundColor * BackgroundOpacity
+ );
+ DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right);
+ DrawSlot(
+ b,
+ rightOrigin.Add(
+ -Scale(SLOT_DISTANCE * MENU_SCALE),
+ -Scale(SLOT_DISTANCE * MENU_SCALE)
+ ),
+ SButton.ControllerY,
+ PromptPosition.Above
+ );
+ DrawSlot(
+ b,
+ rightOrigin.Add(
+ -Scale(SLOT_DISTANCE * MENU_SCALE),
+ Scale(SLOT_DISTANCE * MENU_SCALE)
+ ),
+ SButton.ControllerA,
+ PromptPosition.Below
+ );
+ DrawSlot(
+ b,
+ rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE * 2f)),
+ SButton.ControllerX,
+ PromptPosition.Left
+ );
+ rightShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE);
+ }
+ if (enabledSlots.Contains(SButton.RightShoulder))
+ {
+ DrawSlot(
+ b,
+ rightShoulderPosition,
+ SButton.RightShoulder,
+ PromptPosition.Above,
+ darken: true
+ );
+ }
+ }
+
+ private void UpdateScale(bool force = false)
+ {
+ var desiredScale = Math.Clamp(config.Style.QuickActionScale, 0.5f, 1.5f);
+ if (
+ !force
+ && MathF.Abs(desiredScale - quickSlotScale) < 0.001f
+ && outerBackground is not null
+ )
+ {
+ return;
+ }
+ quickSlotScale = desiredScale;
+ outerBackground?.Dispose();
+ slotBackground?.Dispose();
+ outerBackground = ShapeTexture.CreateCircle(
+ Scale((SLOT_SIZE + SLOT_SIZE / 2f + MARGIN_OUTER) * MENU_SCALE),
+ filled: true,
+ graphicsDevice: graphicsDevice
+ );
+ slotBackground = ShapeTexture.CreateCircle(
+ Scale(SLOT_SIZE / 2f),
+ filled: true,
+ graphicsDevice: graphicsDevice
+ );
+ isDirty = true;
+ }
+
+ private int Scale(float value)
+ {
+ return (int)MathF.Round(value * quickSlotScale);
+ }
+
+ public void FlashDelay(SButton button)
+ {
+ flashes[button] = new(FlashType.Delay, config.Input.ActivationDelayMs);
+ }
+
+ public void FlashError(SButton button)
+ {
+ flashes[button] = new(FlashType.Error, Animation.ERROR_FLASH_DURATION_MS);
+ }
+
+ public bool HasSlotSprite(SButton button)
+ {
+ return slotSprites.ContainsKey(button);
+ }
+
+ public void Invalidate()
+ {
+ isDirty = true;
+ }
+
+ public void Update(TimeSpan elapsed)
+ {
+ foreach (var (button, flash) in flashes)
+ {
+ var flashElapsedMs = flash.ElapsedMs + (float)elapsed.TotalMilliseconds;
+ if (flashElapsedMs >= flash.DurationMs)
+ {
+ flashes.Remove(button);
+ continue;
+ }
+ flashes[button] = flash with { ElapsedMs = flashElapsedMs };
+ }
+ }
+
+ private void DrawSlot(
+ SpriteBatch b,
+ Point origin,
+ SButton button,
+ PromptPosition promptPosition,
+ bool darken = false
+ )
+ {
+ var isAssigned = slotSprites.TryGetValue(button, out var sprite);
+ if (!isAssigned && !UnassignedButtonsVisible)
+ {
+ return;
+ }
+
+ var isEnabled = enabledSlots.Contains(button);
+ var backgroundRect = GetCircleRect(origin, Scale(SLOT_SIZE * SLOT_CONTENT_SCALE / 2f));
+ if (darken)
+ {
+ var darkenRect = backgroundRect;
+ darkenRect.Inflate(4, 4);
+ b.Draw(slotBackground, darkenRect, Color.Black * BackgroundOpacity);
+ }
+ var backgroundColor = GetBackgroundColor(button, isAssigned && isEnabled);
+ b.Draw(slotBackground, backgroundRect, backgroundColor * BackgroundOpacity);
+
+ var slotOpacity = isEnabled ? 1f : 0.5f;
+
+ if (isAssigned)
+ {
+ var spriteRect = GetCircleRect(
+ origin,
+ Scale(IMAGE_SIZE * MENU_SCALE * SLOT_CONTENT_SCALE / 2f)
+ );
+ if (SlotItems.TryGetValue(button, out var item) && item.Texture is not null)
+ {
+ ItemRenderer.Draw(
+ b,
+ item,
+ spriteRect,
+ config.Style,
+ opacity: slotOpacity * SpriteOpacity
+ );
+ }
+ else
+ {
+ b.Draw(
+ sprite!.Texture,
+ spriteRect,
+ sprite.SourceRect,
+ Color.White * slotOpacity * SpriteOpacity
+ );
+ }
+ }
+
+ if (GetPromptSprite(button) is { } promptSprite)
+ {
+ var promptOffset = Scale(PROMPT_OFFSET * SLOT_CONTENT_SCALE);
+ var promptOrigin = promptPosition switch
+ {
+ PromptPosition.Above => origin.AddY(-promptOffset),
+ PromptPosition.Below => origin.AddY(promptOffset),
+ PromptPosition.Left => origin.AddX(-promptOffset),
+ PromptPosition.Right => origin.AddX(promptOffset),
+ _ => throw new ArgumentException(
+ $"Invalid prompt position: {promptPosition}",
+ nameof(promptPosition)
+ ),
+ };
+ var promptRect = GetCircleRect(
+ promptOrigin,
+ Scale(PROMPT_SIZE * SLOT_CONTENT_SCALE / 2f)
+ );
+ b.Draw(
+ promptSprite.Texture,
+ promptRect,
+ promptSprite.SourceRect,
+ Color.White * slotOpacity * SpriteOpacity
+ );
+ }
+ }
+
+ private Color GetBackgroundColor(SButton button, bool enabled)
+ {
+ var baseColor = enabled ? innerBackgroundColor : disabledBackgroundColor;
+ if (!flashes.TryGetValue(button, out var flash))
+ {
+ return baseColor;
+ }
+ var (flashColor, position) = flash.Type switch
+ {
+ FlashType.Delay => (
+ config.Style.HighlightColor,
+ Animation.GetDelayFlashPosition(flash.ElapsedMs)
+ ),
+ FlashType.Error => (Color.Red, Animation.GetErrorFlashPosition(flash.ElapsedMs)),
+ _ => (Color.White, 0),
+ };
+ return Color.Lerp(baseColor, flashColor, position);
+ }
+
+ private static Rectangle GetCircleRect(Point center, int radius)
+ {
+ int length = radius * 2;
+ return new(center.X - radius, center.Y - radius, length, length);
+ }
+
+ private static Sprite GetIconSprite(IconConfig icon)
+ {
+ return !string.IsNullOrEmpty(icon.ItemId)
+ ? Sprite.ForItemId(icon.ItemId)
+ : Sprite.TryLoad(icon.TextureAssetPath, icon.SourceRect)
+ ?? Sprite.ForItemId("Error_Invalid");
+ }
+
+ private Sprite? GetModItemSprite(string id)
+ {
+ var itemConfig = config
+ .Items.ModMenuPages.SelectMany(items => items)
+ .FirstOrDefault(item => item.Id == id);
+ return itemConfig?.Icon is { } icon ? GetIconSprite(icon) : null;
+ }
+
+ private Sprite? GetPromptSprite(SButton button)
+ {
+ var (rowIndex, columnIndex) = button switch
+ {
+ SButton.DPadUp => (1, 0),
+ SButton.DPadRight => (1, 1),
+ SButton.DPadDown => (1, 2),
+ SButton.DPadLeft => (1, 3),
+ SButton.ControllerA => (1, 4),
+ SButton.ControllerB => (1, 5),
+ SButton.ControllerX => (1, 6),
+ SButton.ControllerY => (1, 7),
+ SButton.LeftTrigger => (2, 0),
+ SButton.RightTrigger => (2, 1),
+ SButton.LeftShoulder => (2, 2),
+ SButton.RightShoulder => (2, 3),
+ SButton.ControllerBack => (2, 4),
+ SButton.ControllerStart => (2, 5),
+ SButton.LeftStick => (2, 6),
+ SButton.RightStick => (2, 7),
+ _ => (-1, -1),
+ };
+ if (columnIndex == -1)
+ {
+ return null;
+ }
+ var texture = GetUiTexture();
+ return new(texture, new(columnIndex * 16, rowIndex * 16, 16, 16));
+ }
+
+ private Texture2D GetUiTexture()
+ {
+ var desiredSet = config.Style.ButtonIconSet;
+ if (uiTexture is null || uiTextureIconSet != desiredSet)
+ {
+ var assetPath =
+ desiredSet == ButtonIconSet.PlayStation
+ ? Sprites.UI_PLAYSTATION_TEXTURE_PATH
+ : Sprites.UI_TEXTURE_PATH;
+ uiTexture = Game1.content.Load(assetPath);
+ uiTextureIconSet = desiredSet;
+ }
+ return uiTexture;
+ }
+
+ private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection
- inventoryItems)
+ {
+ if (string.IsNullOrWhiteSpace(itemLookup.Id))
+ return null;
+
+ return itemLookup.IdType switch
+ {
+ ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem(
+ itemLookup.Id,
+ itemLookup.SubId,
+ inventoryItems
+ )
+ is Item item
+ ? Sprite.FromItem(item) // ✅ handles Item Bags via drawInMenu fallback
+ : Sprite.ForItemId(itemLookup.Id), // fallback for normal items
+
+ ItemIdType.ModItem => GetModItemSprite(itemLookup.Id),
+ _ => null,
+ };
+ }
+
+ private static Color LumaGray(Color color, float lightness)
+ {
+ var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness);
+ return new(v, v, v);
+ }
+
+ private void RefreshSlots()
+ {
+ Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data.");
+ enabledSlots.Clear();
+ slotSprites.Clear();
+ foreach (var (button, slotConfig) in Slots)
+ {
+ Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}...");
+ Sprite? sprite = null;
+ if (SlotItems.TryGetValue(button, out var item))
+ {
+ if (item.Texture is not null)
+ {
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"Using configured item sprite for {item.Title} in {button} slot."
+ );
+ sprite = new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds);
+ }
+ else
+ {
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"Item {item.Title} in {button} slot has no texture; using default sprite."
+ );
+ }
+ enabledSlots.Add(button);
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"Enabled {button} slot with '{item.Title}'.",
+ LogLevel.Info
+ );
+ }
+ else
+ {
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"Disabled unassigned {button} slot.",
+ LogLevel.Info
+ );
+ }
+ sprite ??= GetSlotSprite(slotConfig, Game1.player.Items);
+ if (sprite is not null)
+ {
+ slotSprites.Add(button, sprite);
+ }
+ }
+ isDirty = false;
+ }
+}
+
+file static class PointExtensions
+{
+ public static Point Add(this Point point, int x, int y)
+ {
+ return new(point.X + x, point.Y + y);
+ }
+
+ public static Point AddX(this Point point, int x)
+ {
+ return new(point.X + x, point.Y);
+ }
+
+ public static Point AddY(this Point point, int y)
+ {
+ return new(point.X, point.Y + y);
+ }
+}
diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs
index 19b3e92..204c962 100644
--- a/StarControl/Menus/QuickSlotResolver.cs
+++ b/StarControl/Menus/QuickSlotResolver.cs
@@ -1,133 +1,160 @@
-using System.Reflection;
-using StardewValley.ItemTypeDefinitions;
-using StardewValley.Tools;
-
-namespace StarControl.Menus;
-
-internal class QuickSlotResolver(Farmer player, ModMenu modMenu)
-{
- 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)
- {
- Logger.Log(
- LogCategory.QuickSlots,
- $"'{id}' does not have validx item data; aborting search."
- );
- return null;
- }
- // Melee weapons don't have upgrades or base items, but if we didn't find an exact match, it
- // is often helpful to find any other melee weapon that's available.
- // Only apply fuzzy matching to melee weapons; slingshots must match exactly
- if (
- data.ItemType.Identifier == "(W)"
- && !data.QualifiedItemId.Contains("Slingshot")
- && !data.QualifiedItemId.Contains("Bow")
- )
- {
- // We'll match scythes to scythes, and non-scythes to non-scythes.
- // Most likely, the player wants Iridium Scythe if the slot says Scythe. The upgraded
- // version is simply better, like a more typical tool.
- //
- // With real weapons it's fuzzier because the highest-level weapon isn't necessarily
- // appropriate for the situation. If there's one quick slot for Galaxy Sword and another
- // for Galaxy Hammer, then activating those slots should activate their *specific*
- // weapons respectively if both are in the inventory.
- //
- // So we match on the inferred "type" (scythe vs. weapon) and then for non-scythe
- // weapons specifically (and only those), give preference to exact matches before
- // sorting by level.
- var isScythe = IsScythe(data);
- Logger.Log(
- LogCategory.QuickSlots,
- $"Item '{id}' appears to be a weapon with (scythe = {isScythe})."
- );
- var bestWeapon = items
- .OfType()
- .Where(weapon => weapon.Name.Contains("Scythe") == isScythe)
- .OrderByDescending(weapon => !isScythe && weapon.QualifiedItemId == id)
- .ThenByDescending(weapon => weapon.getItemLevel())
- .FirstOrDefault();
- if (bestWeapon is not null)
- {
- Logger.Log(
- LogCategory.QuickSlots,
- "Best weapon match in inventory is "
- + $"{bestWeapon.Name} with ID {bestWeapon.QualifiedItemId}."
- );
- return bestWeapon;
- }
- }
- var baseItem = data.GetBaseItem();
- Logger.Log(
- LogCategory.QuickSlots,
- "Searching for regular item using base item "
- + $"{baseItem.InternalName} with ID {baseItem.QualifiedItemId}."
- );
- var match = items
- .Where(item => item is not null)
- .Where(item =>
- item.QualifiedItemId == id
- || ItemRegistry
- .GetDataOrErrorItem(item.QualifiedItemId)
- .GetBaseItem()
- .QualifiedItemId == baseItem.QualifiedItemId
- )
- .OrderByDescending(item => item is Tool tool ? tool.UpgradeLevel : 0)
- .ThenByDescending(item => item.Quality)
- .FirstOrDefault();
- Logger.Log(
- LogCategory.QuickSlots,
- $"Best match by quality/upgrade level is "
- + $"{match?.Name ?? "(nothing)"} with ID {match?.QualifiedItemId ?? "N/A"}."
- );
- return match;
- }
-
- private static bool IsScythe(ParsedItemData data)
- {
- var method = typeof(MeleeWeapon).GetMethod(
- "IsScythe",
- BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static,
- binder: null,
- types: [typeof(string)],
- modifiers: null
- );
- if (method is not null)
- {
- try
- {
- return (bool)method.Invoke(null, [data.QualifiedItemId])!;
- }
- catch
- {
- // Fall through to heuristic below.
- }
- }
- var name = data.InternalName ?? string.Empty;
- return name.Contains("Scythe", StringComparison.OrdinalIgnoreCase)
- || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase);
- }
-
- public IRadialMenuItem? ResolveItem(string id, ItemIdType idType)
- {
- if (string.IsNullOrEmpty(id))
- {
- return null;
- }
- return idType switch
- {
- ItemIdType.GameItem => ResolveInventoryItem(id, player.Items) is { } item
- ? new InventoryMenuItem(item)
- : null,
- ItemIdType.ModItem => modMenu.GetItem(id),
- _ => null,
- };
- }
-}
+using System.Reflection;
+using StardewValley.ItemTypeDefinitions;
+using StardewValley.Tools;
+
+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
+ && Compat.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)
+ {
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"'{id}' does not have validx item data; aborting search."
+ );
+ return null;
+ }
+ // Melee weapons don't have upgrades or base items, but if we didn't find an exact match, it
+ // is often helpful to find any other melee weapon that's available.
+ // Only apply fuzzy matching to melee weapons; slingshots must match exactly
+ if (
+ data.ItemType.Identifier == "(W)"
+ && !data.QualifiedItemId.Contains("Slingshot")
+ && !data.QualifiedItemId.Contains("Bow")
+ )
+ {
+ // We'll match scythes to scythes, and non-scythes to non-scythes.
+ // Most likely, the player wants Iridium Scythe if the slot says Scythe. The upgraded
+ // version is simply better, like a more typical tool.
+ //
+ // With real weapons it's fuzzier because the highest-level weapon isn't necessarily
+ // appropriate for the situation. If there's one quick slot for Galaxy Sword and another
+ // for Galaxy Hammer, then activating those slots should activate their *specific*
+ // weapons respectively if both are in the inventory.
+ //
+ // So we match on the inferred "type" (scythe vs. weapon) and then for non-scythe
+ // weapons specifically (and only those), give preference to exact matches before
+ // sorting by level.
+ var isScythe = IsScythe(data);
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"Item '{id}' appears to be a weapon with (scythe = {isScythe})."
+ );
+ var bestWeapon = items
+ .OfType()
+ .Where(weapon => weapon.Name.Contains("Scythe") == isScythe)
+ .OrderByDescending(weapon => !isScythe && weapon.QualifiedItemId == id)
+ .ThenByDescending(weapon => weapon.getItemLevel())
+ .FirstOrDefault();
+ if (bestWeapon is not null)
+ {
+ Logger.Log(
+ LogCategory.QuickSlots,
+ "Best weapon match in inventory is "
+ + $"{bestWeapon.Name} with ID {bestWeapon.QualifiedItemId}."
+ );
+ return bestWeapon;
+ }
+ }
+ var baseItem = data.GetBaseItem();
+ Logger.Log(
+ LogCategory.QuickSlots,
+ "Searching for regular item using base item "
+ + $"{baseItem.InternalName} with ID {baseItem.QualifiedItemId}."
+ );
+ var match = items
+ .Where(item => item is not null)
+ .Where(item =>
+ item.QualifiedItemId == id
+ || ItemRegistry
+ .GetDataOrErrorItem(item.QualifiedItemId)
+ .GetBaseItem()
+ .QualifiedItemId == baseItem.QualifiedItemId
+ )
+ .OrderByDescending(item => item is Tool tool ? tool.UpgradeLevel : 0)
+ .ThenByDescending(item => item.Quality)
+ .FirstOrDefault();
+ Logger.Log(
+ LogCategory.QuickSlots,
+ $"Best match by quality/upgrade level is "
+ + $"{match?.Name ?? "(nothing)"} with ID {match?.QualifiedItemId ?? "N/A"}."
+ );
+ return match;
+ }
+
+ private static bool IsScythe(ParsedItemData data)
+ {
+ var method = typeof(MeleeWeapon).GetMethod(
+ "IsScythe",
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static,
+ binder: null,
+ types: [typeof(string)],
+ modifiers: null
+ );
+ if (method is not null)
+ {
+ try
+ {
+ return (bool)method.Invoke(null, [data.QualifiedItemId])!;
+ }
+ catch
+ {
+ // Fall through to heuristic below.
+ }
+ }
+ var name = data.InternalName ?? string.Empty;
+ return name.Contains("Scythe", StringComparison.OrdinalIgnoreCase)
+ || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public IRadialMenuItem? ResolveItem(string id, ItemIdType idType)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ return null;
+ }
+ return idType switch
+ {
+ ItemIdType.GameItem => ResolveInventoryItem(id, player.Items) 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, player.Items) is { } item
+ ? new InventoryMenuItem(item)
+ : null,
+ ItemIdType.ModItem => modMenu.GetItem(id),
+ _ => null,
+ };
+ }
+}
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(
From 0d22fff25aa6f6626b58dc818ff53ee7374b7458 Mon Sep 17 00:00:00 2001
From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com>
Date: Sat, 24 Jan 2026 17:07:47 -0500
Subject: [PATCH 13/24] Add files via upload
---
.../UI/QuickSlotConfigurationViewModel.cs | 16 +-
.../QuickSlotGroupConfigurationViewModel.cs | 2 +
.../UI/QuickSlotPickerItemViewModel.cs | 18 +-
StarControl/UI/RemappingViewModel.cs | 980 +++++++++---------
4 files changed, 531 insertions(+), 485 deletions(-)
diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs
index 7ac418c..280ead1 100644
--- a/StarControl/UI/QuickSlotConfigurationViewModel.cs
+++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs
@@ -34,6 +34,9 @@ internal partial class QuickSlotConfigurationViewModel
[Notify]
private ParsedItemData? itemData;
+ [Notify]
+ private string? itemSubId;
+
[Notify]
private ModMenuItemConfigurationViewModel? modAction;
@@ -48,6 +51,7 @@ public void Clear()
ItemData = null;
ModAction = null;
UseSecondaryAction = false;
+ ItemSubId = null;
}
private Sprite? GetIcon()
@@ -59,7 +63,11 @@ public void Clear()
if (ItemData.IsErrorItem)
{
var invItem = Game1.player?.Items?.FirstOrDefault(i =>
- i is not null && i.QualifiedItemId == ItemData.QualifiedItemId
+ i is not null
+ && i.QualifiedItemId == ItemData.QualifiedItemId
+ && (
+ ItemSubId is null || Compat.ItemBagsIdentity.TryGetBagTypeId(i) == ItemSubId
+ )
);
if (invItem is not null)
@@ -83,7 +91,11 @@ private TooltipData GetTooltip()
if (ItemData.IsErrorItem)
{
var invItem = Game1.player?.Items?.FirstOrDefault(i =>
- i is not null && i.QualifiedItemId == ItemData.QualifiedItemId
+ i is not null
+ && i.QualifiedItemId == ItemData.QualifiedItemId
+ && (
+ ItemSubId is null || Compat.ItemBagsIdentity.TryGetBagTypeId(i) == ItemSubId
+ )
);
if (invItem is not null)
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 3eb9fcb..59fb4bb 100644
--- a/StarControl/UI/QuickSlotPickerItemViewModel.cs
+++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs
@@ -76,7 +76,11 @@ public static QuickSlotPickerItemViewModel ForItem(Item item)
{
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(),
@@ -94,7 +98,11 @@ public static QuickSlotPickerItemViewModel ForItem(Item item)
{
var sprite = Sprite.FromItem(item);
return new(
- slot => slot.ItemData = data.GetBaseItem(),
+ slot =>
+ {
+ slot.ItemData = data.GetBaseItem();
+ slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item);
+ },
sprite.Texture,
sprite.SourceRect,
tooltip: tooltip
@@ -114,7 +122,11 @@ public static QuickSlotPickerItemViewModel ForItem(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/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs
index 662be70..66ebfe9 100644
--- a/StarControl/UI/RemappingViewModel.cs
+++ b/StarControl/UI/RemappingViewModel.cs
@@ -1,480 +1,500 @@
-using System.Collections;
-using System.Reflection;
-using Microsoft.Xna.Framework;
-using Microsoft.Xna.Framework.Input;
-using PropertyChanged.SourceGenerator;
-using StarControl.Config;
-using StarControl.Data;
-using StarControl.Graphics;
-using StarControl.Menus;
-
-namespace StarControl.UI;
-
-internal partial class RemappingViewModel(
- IInputHelper inputHelper,
- Farmer who,
- IEnumerable modItems,
- SButton menuToggleButton,
- float thumbstickDeadZone,
- ButtonIconSet buttonIconSet,
- Action> onSave
-)
-{
- private const int BaseMenuWidth = 950;
- private const int BaseMenuHeight = 700;
- private const int MinMenuWidth = 720;
- private const int MinMenuHeight = 520;
- private const int ViewportPaddingWidth = 128;
- private const int ViewportPaddingHeight = 120;
- private const int MenuVerticalNudge = -24;
-
- public IMenuController? Controller { get; set; }
- public bool IsItemHovered => HoveredItem is not null;
- public bool IsSlotHovered => HoveredSlot is not null;
- public bool IsSlotHoveredAndAssigned => HoveredSlot?.Item is not null;
- public IReadOnlyList ItemGroups { get; } =
- [
- new()
- {
- Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(),
- Items = who
- .Items.Where(item => item is not null)
- .Select(RemappableItemViewModel.FromInventoryItem)
- .ToList(),
- },
- new()
- {
- Name = I18n.Enum_QuickSlotItemSource_ModItems_Name(),
- Items = modItems.Select(RemappableItemViewModel.FromMenuItem).ToList(),
- },
- ];
- public IEnumerable Slots => slotsByButton.Values;
-
- [Notify]
- private ButtonIconSet buttonIconSet = buttonIconSet;
-
- [Notify]
- private string menuLayout = $"{BaseMenuWidth}px {BaseMenuHeight}px";
-
- [Notify]
- private bool canReassign;
-
- [Notify]
- private RemappableItemViewModel? hoveredItem;
-
- [Notify]
- private RemappingSlotViewModel? hoveredSlot;
-
- [Notify]
- private bool showAssignTip = true;
-
- [Notify]
- private bool showUnassignTip;
-
- private const double RightStickScrollRepeatMinMs = 35;
- private const double RightStickScrollRepeatMaxMs = 140;
-
- private double lastRightStickScrollMs;
- private bool menuLayoutInitialized;
- private int menuWidth = BaseMenuWidth;
- private int menuHeight = BaseMenuHeight;
- private readonly Dictionary slotsByButton = new()
- {
- { SButton.DPadLeft, new(SButton.DPadLeft) },
- { SButton.DPadUp, new(SButton.DPadUp) },
- { SButton.DPadRight, new(SButton.DPadRight) },
- { SButton.DPadDown, new(SButton.DPadDown) },
- { SButton.ControllerX, new(SButton.ControllerX) },
- { SButton.ControllerY, new(SButton.ControllerY) },
- { SButton.ControllerB, new(SButton.ControllerB) },
- { SButton.LeftShoulder, new(SButton.LeftShoulder) },
- { SButton.RightShoulder, new(SButton.RightShoulder) },
- };
-
- public bool AssignToSlot(SButton button, RemappableItemViewModel item)
- {
- if (!CanReassign || !slotsByButton.TryGetValue(button, out var slot))
- {
- return false;
- }
- Game1.playSound("drumkit6");
- if (slot.Item is not null)
- {
- slot.Item.AssignedButton = SButton.None;
- }
- item.AssignedButton = button;
- slot.Item = item;
- Save();
- return true;
- }
-
- public void Load(Dictionary data)
- {
- foreach (var slot in slotsByButton.Values)
- {
- if (slot.Item is { } previousItem)
- {
- previousItem.AssignedButton = SButton.None;
- }
- slot.Item = null;
- }
- foreach (var (button, slotData) in data)
- {
- if (
- string.IsNullOrEmpty(slotData.Id)
- || !slotsByButton.TryGetValue(button, out var slot)
- )
- {
- continue;
- }
- var item = slotData.IdType switch
- {
- ItemIdType.GameItem => ItemGroups[0]
- .Items.FirstOrDefault(item => item.Id == slotData.Id)
- ?? RemappableItemViewModel.FromInventoryItem(
- ItemRegistry.Create(slotData.Id),
- who.Items
- ),
- ItemIdType.ModItem => ItemGroups[1]
- .Items.FirstOrDefault(item => item.Id == slotData.Id),
- _ => null,
- };
- item ??= RemappableItemViewModel.Invalid(slotData.IdType, slotData.Id);
- item.AssignedButton = button;
- slot.Item = item;
- }
- UpdateTipVisibility();
- }
-
- public void Save()
- {
- var data = new Dictionary();
- foreach (var (button, slot) in slotsByButton)
- {
- if (slot.Item is { } item && !string.IsNullOrEmpty(item.Id))
- {
- data[button] = new() { Id = slot.Item.Id, IdType = slot.Item.IdType };
- }
- }
- onSave(data);
- }
-
- public void SetItemHovered(RemappableItemViewModel? item)
- {
- if (HoveredItem == item)
- {
- return;
- }
- if (HoveredItem is not null)
- {
- HoveredItem.Hovered = false;
- }
- HoveredItem = item;
- if (item is not null)
- {
- item.Hovered = true;
- }
- }
-
- public void SetSlotHovered(RemappingSlotViewModel? slot)
- {
- HoveredSlot = slot;
- UpdateTipVisibility();
- }
-
- public void UnassignSlot(RemappingSlotViewModel slot)
- {
- if (slot.Item is null)
- {
- return;
- }
- Game1.playSound("trashcan");
- slot.Item.AssignedButton = SButton.None;
- slot.Item = null;
- OnPropertyChanged(new(nameof(IsSlotHoveredAndAssigned)));
- UpdateTipVisibility();
- Save();
- }
-
- public void Update()
- {
- if (!menuLayoutInitialized)
- {
- UpdateLayoutForViewport();
- menuLayoutInitialized = true;
- }
- CanReassign =
- inputHelper.IsDown(SButton.LeftTrigger) || inputHelper.IsDown(SButton.RightTrigger);
- HandleRightStickScroll();
- // IClickableMenu.receiveGamePadButton bizarrely does not receive some buttons such as the
- // left/right stick. We have to check them for through the helper.
- if (
- !CanReassign
- && menuToggleButton
- is not SButton.DPadUp
- or SButton.DPadDown
- or SButton.DPadLeft
- or SButton.DPadRight
- && inputHelper.GetState(menuToggleButton) == SButtonState.Pressed
- )
- {
- Controller?.Close();
- }
- }
-
- public void InitializeLayout()
- {
- menuLayoutInitialized = false;
- UpdateLayoutForViewport();
- menuLayoutInitialized = true;
- }
-
- public Point GetMenuPosition()
- {
- var viewport = Game1.uiViewport;
- var x = (viewport.Width - menuWidth) / 2;
- var y = (viewport.Height - menuHeight) / 2 + MenuVerticalNudge;
- return new Point(Math.Max(0, x), Math.Max(0, y));
- }
-
- private void UpdateLayoutForViewport()
- {
- var viewport = Game1.uiViewport;
- var width = Math.Min(
- BaseMenuWidth,
- Math.Max(MinMenuWidth, viewport.Width - ViewportPaddingWidth)
- );
- var height = Math.Min(
- BaseMenuHeight,
- Math.Max(MinMenuHeight, viewport.Height - ViewportPaddingHeight)
- );
- menuWidth = width;
- menuHeight = height;
- MenuLayout = $"{width}px {height}px";
- }
-
- private void UpdateTipVisibility()
- {
- ShowUnassignTip = IsSlotHoveredAndAssigned;
- ShowAssignTip = !ShowUnassignTip;
- }
-
- private void HandleRightStickScroll()
- {
- if (Controller?.Menu is null)
- {
- return;
- }
- var nowMs = Game1.currentGameTime?.TotalGameTime.TotalMilliseconds ?? 0;
- var state = Game1.playerOneIndex >= PlayerIndex.One ? Game1.input.GetGamePadState() : new();
- var stickY = state.ThumbSticks.Right.Y;
- var absY = Math.Abs(stickY);
- if (absY <= thumbstickDeadZone)
- {
- return;
- }
- var intensity = Math.Clamp((absY - thumbstickDeadZone) / (1f - thumbstickDeadZone), 0f, 1f);
- var repeatMs =
- RightStickScrollRepeatMaxMs
- - (RightStickScrollRepeatMaxMs - RightStickScrollRepeatMinMs) * intensity;
- if (nowMs - lastRightStickScrollMs < repeatMs)
- {
- return;
- }
- lastRightStickScrollMs = nowMs;
- TryScrollActiveContainer(stickY > 0);
- }
-
- private bool TryScrollActiveContainer(bool scrollUp)
- {
- var container = GetActiveScrollContainer();
- if (container is null)
- {
- return false;
- }
- var containerType = container.GetType();
- var scrollSizeProp = containerType.GetProperty("ScrollSize");
- var scrollSizeValue = scrollSizeProp?.GetValue(container);
- var scrollSize = scrollSizeValue is float size ? size : 0f;
- if (scrollSize <= 0f)
- {
- return false;
- }
- var methodName = scrollUp ? "ScrollBackward" : "ScrollForward";
- var scrollMethod = containerType.GetMethod(methodName);
- if (scrollMethod is null)
- {
- return false;
- }
- var result = scrollMethod.Invoke(container, Array.Empty