diff --git a/S1API/Entities/Player.cs b/S1API/Entities/Player.cs
index 1c3b9da7..40cddacb 100644
--- a/S1API/Entities/Player.cs
+++ b/S1API/Entities/Player.cs
@@ -11,8 +11,10 @@
using System.Linq;
using System.Reflection;
using HarmonyLib;
+using S1API.Avatar;
using S1API.Entities.Interfaces;
using S1API.Internal.Abstraction;
+using S1API.Items;
using S1API.Law;
using UnityEngine;
using S1API.Vehicles;
@@ -273,6 +275,64 @@ public PropertyWrapper? LastVisitedProperty {
///
public object CurrentAvatarSettings => S1Player.CurrentAvatarSettings;
+ ///
+ /// Retrieves the player's current avatar settings as an S1API wrapper.
+ /// Returns a new instance on each call when S1Player.CurrentAvatarSettings is available.
+ ///
+ public BasicAvatarSettings? GetCurrentBasicAvatarSettings() =>
+ S1Player.CurrentAvatarSettings == null
+ ? null
+ : new BasicAvatarSettings(S1Player.CurrentAvatarSettings);
+
+ ///
+ /// Inserts clothing into the matching player clothing slot.
+ ///
+ public void InsertClothing(ClothingItemInstance clothing)
+ {
+ if (clothing == null)
+ {
+ throw new ArgumentNullException(nameof(clothing));
+ }
+
+ S1Player.Clothing.InsertClothing(clothing.S1ClothingInstance);
+ }
+
+ ///
+ /// Creates clothing with the definition's default color and inserts it into the matching player clothing slot.
+ ///
+ /// The clothing item definition to equip.
+ /// The clothing instance that was inserted.
+ public ClothingItemInstance EquipClothing(ClothingItemDefinition definition)
+ {
+ if (definition == null)
+ {
+ throw new ArgumentNullException(nameof(definition));
+ }
+
+ ClothingItemInstance clothing = definition.CreateInstance(definition.DefaultColor);
+ InsertClothing(clothing);
+ return clothing;
+ }
+
+ ///
+ /// Refreshes the player's avatar from the current clothing slots.
+ ///
+ public void RefreshClothingAppearance() =>
+ S1Player.Clothing.RefreshAppearance();
+
+ ///
+ /// Sends updated appearance settings through the game's native appearance flow.
+ ///
+ public void SendAppearance(BasicAvatarSettings settings)
+ {
+ if (settings == null)
+ {
+ throw new ArgumentNullException(nameof(settings));
+ }
+
+ S1Player.SendAppearance(settings.S1BasicAvatarSettings);
+ }
+
///
/// Revives the player.
///
@@ -318,6 +378,15 @@ public event Action OnDeath
remove => EventHelper.RemoveListener(value, S1Player.Health.onDie);
}
+ ///
+ /// Called when the player revives.
+ ///
+ public event Action OnRevive
+ {
+ add => EventHelper.AddListener(value, S1Player.Health.onRevive);
+ remove => EventHelper.RemoveListener(value, S1Player.Health.onRevive);
+ }
+
private static readonly HashSet InvinciblePlayers = new HashSet();
internal static bool IsPlayerInvincible(S1PlayerScripts.Player player) => InvinciblePlayers.Contains(player);
diff --git a/S1API/Items/ClothingItemCreator.cs b/S1API/Items/ClothingItemCreator.cs
index 546d79ea..320a3993 100644
--- a/S1API/Items/ClothingItemCreator.cs
+++ b/S1API/Items/ClothingItemCreator.cs
@@ -14,7 +14,7 @@ namespace S1API.Items
{
///
/// Provides convenient static methods for creating custom clothing items.
- /// Use for flexible configuration or for variants.
+ /// Use for flexible configuration or for variants.
///
public static class ClothingItemCreator
{
diff --git a/S1API/Items/ClothingItemDefinition.cs b/S1API/Items/ClothingItemDefinition.cs
index eb816233..b6b25d52 100644
--- a/S1API/Items/ClothingItemDefinition.cs
+++ b/S1API/Items/ClothingItemDefinition.cs
@@ -15,8 +15,8 @@ namespace S1API.Items
/// Extends with clothing-specific properties.
///
///
- /// Use to create new clothing items,
- /// or to create variants of existing items.
+ /// Use to create new clothing items,
+ /// or to create variants of existing items.
///
public sealed class ClothingItemDefinition : StorableItemDefinition
{
@@ -34,6 +34,34 @@ internal ClothingItemDefinition(S1Clothing.ClothingDefinition definition)
///
internal S1Clothing.ClothingDefinition S1ClothingDefinition { get; }
+ ///
+ /// Creates a clothing instance from this definition using the default color.
+ ///
+ /// The quantity to apply to the created clothing instance.
+ /// A clothing item instance using this definition's default color.
+ public override ItemInstance CreateInstance(int quantity = 1) =>
+ CreateInstance(quantity, DefaultColor);
+
+ ///
+ /// Creates a clothing instance from this definition with the specified color.
+ ///
+ /// The clothing color to apply to the created instance.
+ /// A clothing instance using the specified color.
+ public ClothingItemInstance CreateInstance(ClothingColor color) =>
+ CreateInstance(1, color);
+
+ ///
+ /// Creates a clothing instance from this definition with the specified quantity and color.
+ ///
+ /// The quantity to apply to the created clothing instance.
+ /// The clothing color to apply to the created instance.
+ /// A clothing instance using the specified quantity and color.
+ public ClothingItemInstance CreateInstance(int quantity, ClothingColor color) =>
+ new ClothingItemInstance(new S1Clothing.ClothingInstance(
+ S1ClothingDefinition,
+ quantity,
+ (S1Clothing.EClothingColor)color));
+
///
/// The clothing slot this item occupies.
///
diff --git a/S1API/Items/ClothingItemDefinitionBuilder.cs b/S1API/Items/ClothingItemDefinitionBuilder.cs
index e6bb6f02..506bad10 100644
--- a/S1API/Items/ClothingItemDefinitionBuilder.cs
+++ b/S1API/Items/ClothingItemDefinitionBuilder.cs
@@ -3,16 +3,20 @@
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework;
using S1Registry = Il2CppScheduleOne.Registry;
+using S1UiItems = Il2CppScheduleOne.UI.Items;
using Il2CppCollections = Il2CppSystem.Collections.Generic;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1Clothing = ScheduleOne.Clothing;
using S1ItemFramework = ScheduleOne.ItemFramework;
using S1CoreItemFramework = ScheduleOne.Core.Items.Framework;
using S1Registry = ScheduleOne.Registry;
+using S1UiItems = ScheduleOne.UI.Items;
using Il2CppCollections = System.Collections.Generic;
#endif
using System.Collections.Generic;
+using S1API.Internal.Utils;
+using S1API.Logging;
using UnityEngine;
namespace S1API.Items
@@ -23,6 +27,11 @@ namespace S1API.Items
///
public sealed class ClothingItemDefinitionBuilder
{
+ private static readonly Log Logger = new Log("ClothingItemDefinitionBuilder");
+ private static readonly HashSet WarnedMissingNativeClothingItemUiReasons = new HashSet();
+ private static readonly object WarnedMissingNativeClothingItemUiLock = new object();
+ private static S1UiItems.ItemUI? s_cachedNativeCustomItemUI;
+
private readonly S1Clothing.ClothingDefinition _definition;
///
@@ -33,7 +42,7 @@ internal ClothingItemDefinitionBuilder()
_definition = ScriptableObject.CreateInstance();
// Set defaults
- _definition.StackLimit = 10;
+ _definition.StackLimit = 1;
_definition.BasePurchasePrice = 10f;
_definition.ResellMultiplier = 0.5f;
_definition.Category = S1CoreItemFramework.EItemCategory.Clothing;
@@ -59,6 +68,11 @@ internal ClothingItemDefinitionBuilder()
///
internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source)
{
+ if (source == null)
+ {
+ throw new System.ArgumentNullException(nameof(source));
+ }
+
_definition = ScriptableObject.CreateInstance();
// Copy all StorableItemDefinition properties
@@ -75,6 +89,7 @@ internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source)
_definition.UsableInFilters = source.UsableInFilters;
_definition.StoredItem = source.StoredItem;
_definition.Equippable = source.Equippable;
+ _definition.CustomItemUI = source.CustomItemUI;
// Copy clothing-specific properties
_definition.Slot = source.Slot;
@@ -92,7 +107,9 @@ internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source)
}
}
#else
- _definition.SlotsToBlock = new List(source.SlotsToBlock);
+ _definition.SlotsToBlock = source.SlotsToBlock == null
+ ? new List()
+ : new List(source.SlotsToBlock);
#endif
}
@@ -220,6 +237,8 @@ public ClothingItemDefinitionBuilder WithPricing(float basePurchasePrice, float
/// A wrapper around the created clothing item definition.
public ClothingItemDefinition Build()
{
+ EnsureNativeClothingItemUi();
+
// Register with the game's registry
S1Registry.Instance.AddToRegistry(_definition);
@@ -233,8 +252,80 @@ public ClothingItemDefinition Build()
///
internal S1Clothing.ClothingDefinition BuildInternal()
{
+ EnsureNativeClothingItemUi();
+
return _definition;
}
+
+ private void EnsureNativeClothingItemUi()
+ {
+ if (_definition.CustomItemUI != null)
+ {
+ return;
+ }
+
+ if (s_cachedNativeCustomItemUI != null)
+ {
+ _definition.CustomItemUI = s_cachedNativeCustomItemUI;
+ return;
+ }
+
+ if (S1Registry.Instance == null)
+ {
+ WarnMissingNativeClothingItemUi("S1Registry.Instance is null");
+ return;
+ }
+
+ var allItems = S1Registry.Instance.GetAllItems();
+ if (allItems == null)
+ {
+ WarnMissingNativeClothingItemUi("S1Registry.Instance.GetAllItems() returned null");
+ return;
+ }
+
+ foreach (var item in allItems)
+ {
+ if (item == null ||
+ !CrossType.Is(item, out S1Clothing.ClothingDefinition clothingDefinition))
+ {
+ continue;
+ }
+
+ var customItemUI = clothingDefinition.CustomItemUI;
+ if (customItemUI == null)
+ {
+ continue;
+ }
+
+ // CustomItemUI is a native UI template. Share the existing template instead of
+ // cloning it here; listing state is bound per item by the game, and cloning
+ // Unity/Il2Cpp UI objects is riskier across runtimes.
+ s_cachedNativeCustomItemUI = customItemUI;
+ _definition.CustomItemUI = customItemUI;
+ return;
+ }
+
+ WarnMissingNativeClothingItemUi("no S1Clothing.ClothingDefinition with S1Clothing.ClothingDefinition.CustomItemUI was found");
+ }
+
+ private static void WarnMissingNativeClothingItemUi(string reason)
+ {
+ if (string.IsNullOrWhiteSpace(reason))
+ {
+ return;
+ }
+
+ bool shouldWarn;
+ lock (WarnedMissingNativeClothingItemUiLock)
+ {
+ shouldWarn = WarnedMissingNativeClothingItemUiReasons.Add(reason);
+ }
+
+ if (shouldWarn)
+ {
+ Logger.Warning($"Could not borrow a native clothing CustomItemUI template ({reason}). Custom clothing inventory UI may be incomplete. This usually means Build() was called before any native clothing registered.");
+ }
+ }
}
}
diff --git a/S1API/Items/ItemManager.cs b/S1API/Items/ItemManager.cs
index f6b4be29..b384dfc9 100644
--- a/S1API/Items/ItemManager.cs
+++ b/S1API/Items/ItemManager.cs
@@ -1,12 +1,10 @@
#if (IL2CPPMELON)
-using S1 = Il2CppScheduleOne;
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
using S1Product = Il2CppScheduleOne.Product;
using S1Registry = Il2CppScheduleOne.Registry;
using S1Clothing = Il2CppScheduleOne.Clothing;
using S1Packaging = Il2CppScheduleOne.Product.Packaging;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
-using S1 = ScheduleOne;
using S1ItemFramework = ScheduleOne.ItemFramework;
using S1Product = ScheduleOne.Product;
using S1Registry = ScheduleOne.Registry;
@@ -35,7 +33,7 @@ public static class ItemManager
/// An instance of the item definition.
public static ItemDefinition GetItemDefinition(string itemID)
{
- S1ItemFramework.ItemDefinition itemDefinition = S1.Registry.GetItem(itemID);
+ S1ItemFramework.ItemDefinition itemDefinition = S1Registry.GetItem(itemID);
if (itemDefinition == null)
return null;
@@ -88,6 +86,47 @@ public static void RegisterItem(ItemDefinition definition)
S1Registry.Instance.AddToRegistry(definition.S1ItemDefinition);
}
+ ///
+ /// Checks whether an item ID is present in the active game registry.
+ ///
+ /// The ID of the item to look up.
+ /// True if the item is registered; otherwise, false.
+ public static bool IsItemRegistered(string itemID)
+ {
+ if (string.IsNullOrWhiteSpace(itemID) || S1Registry.Instance == null)
+ {
+ return false;
+ }
+
+ return S1Registry.ItemExists(itemID);
+ }
+
+ ///
+ /// Registers an item only when it is missing from the active game registry.
+ ///
+ /// The item definition to register.
+ /// True if the item is registered after the call; otherwise, false.
+ public static bool EnsureItemRegistered(ItemDefinition definition)
+ {
+ if (definition == null)
+ {
+ throw new ArgumentNullException(nameof(definition), "Cannot register null item definition");
+ }
+
+ if (S1Registry.Instance == null)
+ {
+ return false;
+ }
+
+ if (IsItemRegistered(definition.ID))
+ {
+ return true;
+ }
+
+ S1Registry.Instance.AddToRegistry(definition.S1ItemDefinition);
+ return IsItemRegistered(definition.ID);
+ }
+
///
/// Prevents a runtime-registered item from being removed during the next scene transition.
/// This is useful for items that must survive a menu-to-game load sequence.
diff --git a/S1API/docs/clothing-items.md b/S1API/docs/clothing-items.md
index e5c3ba04..e8c17f78 100644
--- a/S1API/docs/clothing-items.md
+++ b/S1API/docs/clothing-items.md
@@ -4,7 +4,7 @@ Clothing content bridges the avatar rendering system, runtime resource registry,
## Quick Checklist
-1. Wait for the `Main` scene before touching registries (`OnSceneWasLoaded`).
+1. Subscribe to `GameLifecycle.OnPreLoad` once after the `Main` scene starts, then let that callback register clothing before save data loads. Because `OnPreLoad` fires on every load, guard registration with either a manual `ItemManager.IsItemRegistered` check when you need to recover an existing definition, or `ItemManager.EnsureItemRegistered` when you already have the definition instance.
2. (Optional) Clone an existing accessory prefab and override its materials/textures via `AccessoryFactory`.
3. Build or clone the clothing definition with `ClothingItemCreator`, pointing `WithClothingAsset` at your custom accessory path.
4. Register icons and pricing just like other items.
@@ -88,7 +88,7 @@ int shopsAdded = ShopManager.AddToCompatibleShops(itemDefinition);
MelonLogger.Msg($"Added to {shopsAdded} shop(s)");
```
-Shop injection should happen after the registry confirms your item exists (e.g., immediately after `CreateClothingItem()` succeeds).
+Shop injection should happen after the registry confirms your item exists (e.g., immediately after `Build()` succeeds or during `GameLifecycle.OnLoadComplete`). `ShopManager.AddToCompatibleShops` can be called on later loads because shop additions skip existing listings.
## Slots and Application Types
@@ -113,7 +113,7 @@ Shop injection should happen after the registry confirms your item exists (e.g.,
## Testing and Troubleshooting
-- **Initialization timing**: Guard the workflow with `if (sceneName == "Main" && !_initialized)` to avoid duplicate registration across scene loads.
+- **Initialization timing**: Subscribe from the `Main` scene once, register definitions in `GameLifecycle.OnPreLoad`, and add shop entries in `GameLifecycle.OnLoadComplete`. Guard registration with `ItemManager.IsItemRegistered` or a definition lookup so repeated loads do not duplicate custom items.
- **Resource paths**: Match the string passed to `WithClothingAsset` with the `targetResourcePath` you registered via `AccessoryFactory`.
- **Texture validation**: Log texture dimensions after loading so you catch mis-sized PNGs early.
- **Shop coverage**: `ShopManager.AddToCompatibleShops` returns how many inventories accepted the item—log the count and ensure it is non-zero for your desired vendors.
@@ -126,6 +126,7 @@ Here's a complete example combining all steps:
```csharp
using MelonLoader;
using S1API.Items;
+using S1API.Lifecycle;
using S1API.Rendering;
using S1API.Internal.Utils;
using S1API.Shops;
@@ -135,43 +136,70 @@ using UnityEngine;
public class MyMod : MelonMod
{
+ private const string CustomItemId = "custom_cap";
+ private const string CustomAccessoryPath = "MyMod/Accessories/CustomCap";
+ private const string CustomTextureResourceName = "MyMod.Resources.CustomCap.custom_cap_texture.png";
+
private bool _itemsInitialized = false;
+ private ClothingItemDefinition customCap;
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
{
if (sceneName == "Main" && !_itemsInitialized)
{
- InitializeCustomClothing();
+ GameLifecycle.OnPreLoad += InitializeCustomClothing;
+ GameLifecycle.OnLoadComplete += AddCustomClothingToShops;
_itemsInitialized = true;
}
}
private void InitializeCustomClothing()
{
+ if (ItemManager.IsItemRegistered(CustomItemId))
+ {
+ customCap = ItemManager.GetItemDefinition(CustomItemId) as ClothingItemDefinition;
+ if (customCap == null)
+ {
+ MelonLogger.Error($"Registered item '{CustomItemId}' is not a ClothingItemDefinition; custom clothing setup cannot continue.");
+ return;
+ }
+
+ return;
+ }
+
// Step 1: Create and register custom accessory
var assembly = Assembly.GetExecutingAssembly();
- var customTexture = TextureUtils.LoadTextureFromResource(
- assembly,
- "MyMod.Resources.CustomCap.custom_cap_texture.png");
-
- var textureReplacements = new Dictionary
- {
- { "_MainTex", customTexture },
- { "_BaseMap", customTexture },
- { "_Albedo", customTexture }
- };
-
- bool accessoryRegistered = AccessoryFactory.CreateAndRegisterAccessory(
- sourceResourcePath: "avatar/accessories/head/cap/Cap",
- targetResourcePath: "MyMod/Accessories/CustomCap",
- newName: "CustomCap",
- textureReplacements: textureReplacements,
- colorTint: null);
-
- if (!accessoryRegistered)
+ if (!RuntimeResourceRegistry.IsRegistered(CustomAccessoryPath))
{
- MelonLogger.Error("Failed to register custom accessory");
- return;
+ var customTexture = TextureUtils.LoadTextureFromResource(
+ assembly,
+ CustomTextureResourceName);
+
+ if (customTexture == null)
+ {
+ MelonLogger.Error($"Failed to load custom clothing texture resource: {CustomTextureResourceName}");
+ return;
+ }
+
+ var textureReplacements = new Dictionary
+ {
+ { "_MainTex", customTexture },
+ { "_BaseMap", customTexture },
+ { "_Albedo", customTexture }
+ };
+
+ bool accessoryRegistered = AccessoryFactory.CreateAndRegisterAccessory(
+ sourceResourcePath: "avatar/accessories/head/cap/Cap",
+ targetResourcePath: CustomAccessoryPath,
+ newName: "CustomCap",
+ textureReplacements: textureReplacements,
+ colorTint: null);
+
+ if (!accessoryRegistered)
+ {
+ MelonLogger.Error("Failed to register custom accessory");
+ return;
+ }
}
// Step 2: Create clothing item definition
@@ -179,12 +207,12 @@ public class MyMod : MelonMod
assembly,
"MyMod.Resources.CustomCap.icon.png");
- var customCap = ClothingItemCreator.CloneFrom("cap")
+ customCap = ClothingItemCreator.CloneFrom("cap")
.WithBasicInfo(
- id: "custom_cap",
+ id: CustomItemId,
name: "Custom Cap",
description: "A custom cap with unique style.")
- .WithClothingAsset("MyMod/Accessories/CustomCap")
+ .WithClothingAsset(CustomAccessoryPath)
.WithColorable(false)
.WithDefaultColor(ClothingColor.White)
.WithPricing(75f, 0.5f)
@@ -194,10 +222,18 @@ public class MyMod : MelonMod
{
customCap.Icon = icon;
}
+ MelonLogger.Msg($"Created custom clothing item: {customCap.Name}");
+ }
+
+ private void AddCustomClothingToShops()
+ {
+ if (customCap == null)
+ {
+ MelonLogger.Warning($"Skipping shop registration because '{CustomItemId}' was not created as a ClothingItemDefinition.");
+ return;
+ }
- // Step 3: Add to shops
int shopsAdded = ShopManager.AddToCompatibleShops(customCap);
- MelonLogger.Msg($"Created custom clothing item: {customCap.Name}");
MelonLogger.Msg($"Added to {shopsAdded} shop(s)");
}
}