From fdcb3baeaf63823c462f5f6fcd602687cf29e223 Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 21:09:05 -0700 Subject: [PATCH 001/147] chore(release): promote S1API to 3.1.0 --- S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 5bba8a92..4ceaec1e 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -11,7 +11,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.0-beta.9", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.0", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 058682c3..29f4766d 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.1.0-beta.9 + 3.1.0 From 089034d70eee5c125b9b8da8f7942ac3ac87f8f3 Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 22:04:09 -0700 Subject: [PATCH 002/147] fix(ci): use released API compatibility baseline Use the prior shipped Mono assembly when the stable base has no API-input changes since its release tag. Fall back to the source build for unshipped API changes so ApiCompat remains exact and conservative. --- .github/workflows/docs.yml | 46 ++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cf215460..bc2775c1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -311,18 +311,46 @@ jobs: if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail + git checkout --detach --force "$BASE_SHA" - dotnet restore S1API/S1API.csproj -p:Configuration=MonoMelon - dotnet build \ + + baseline_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$BASE_SHA" 2>/dev/null || true)" + if [[ -n "$baseline_tag" ]] && git diff --quiet "$baseline_tag" "$BASE_SHA" -- \ + ':(glob)S1API/**/*.cs' \ S1API/S1API.csproj \ - --no-restore \ - --configuration MonoMelon \ - --verbosity minimal \ - --property:AutomateLocalDeployment=false - cp \ - S1API/bin/MonoMelon/netstandard2.1/S1API.dll \ - "$RUNNER_TEMP/s1api-api-compat/baseline.dll" + S1API/Directory.Build.props \ + Directory.Build.props; then + baseline_version="${baseline_tag#v}" + release_dir="$RUNNER_TEMP/s1api-api-compat/release" + release_zip="$release_dir/S1API-Forked-${baseline_version}.zip" + + mkdir -p "$release_dir" + gh release download "$baseline_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "S1API-Forked-${baseline_version}.zip" \ + --dir "$release_dir" + unzip -p "$release_zip" \ + Mods/S1API.Mono.MelonLoader.dll \ + > "$RUNNER_TEMP/s1api-api-compat/baseline.dll" + echo "Using shipped ${baseline_tag} Mono assembly as the API baseline" + else + dotnet restore S1API/S1API.csproj -p:Configuration=MonoMelon + dotnet build \ + S1API/S1API.csproj \ + --no-restore \ + --configuration MonoMelon \ + --verbosity minimal \ + --property:AutomateLocalDeployment=false + cp \ + S1API/bin/MonoMelon/netstandard2.1/S1API.dll \ + "$RUNNER_TEMP/s1api-api-compat/baseline.dll" + echo "Using target-branch source build as the API baseline" + fi + + test -s "$RUNNER_TEMP/s1api-api-compat/baseline.dll" git checkout --detach --force "$GITHUB_SHA" - name: Restore ApiCompat tool cache From 271404ec566b657a3ea3450aa066ffb61df1d413 Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 22:18:02 -0700 Subject: [PATCH 003/147] fix(phoneapp): wrap exit actions across runtimes Replace the native exit-action parameter with an S1API-owned wrapper and adapt Mono and IL2CPP callbacks internally. Document the unavoidable 3.0.6 signature migration and constrain ApiCompat to one exact upstream-removal suppression. --- .github/api-compat-suppressions.xml | 12 ++++ .github/workflows/docs.yml | 1 + S1API.Tests/PhoneApp/ExitActionTests.cs | 94 +++++++++++++++++++++++++ S1API/PhoneApp/ExitAction.cs | 31 ++++++++ S1API/PhoneApp/PhoneApp.cs | 17 ++++- S1API/docs/phone-app.md | 25 ++++++- 6 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 .github/api-compat-suppressions.xml create mode 100644 S1API.Tests/PhoneApp/ExitActionTests.cs create mode 100644 S1API/PhoneApp/ExitAction.cs diff --git a/.github/api-compat-suppressions.xml b/.github/api-compat-suppressions.xml new file mode 100644 index 00000000..941cf298 --- /dev/null +++ b/.github/api-compat-suppressions.xml @@ -0,0 +1,12 @@ + + + + + CP0002 + M:S1API.PhoneApp.PhoneApp.Exit(ScheduleOne.DevUtilities.ExitAction) + + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bc2775c1..b8bc50bc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -376,6 +376,7 @@ jobs: "$RUNNER_TEMP/apicompat/apicompat" \ --left "$RUNNER_TEMP/s1api-api-compat/baseline.dll" \ --right "$RUNNER_TEMP/s1api-api-compat/current.dll" \ + --suppression-file .github/api-compat-suppressions.xml \ --enable-rule-cannot-change-parameter-name \ --enable-rule-attributes-must-match diff --git a/S1API.Tests/PhoneApp/ExitActionTests.cs b/S1API.Tests/PhoneApp/ExitActionTests.cs new file mode 100644 index 00000000..b5045148 --- /dev/null +++ b/S1API.Tests/PhoneApp/ExitActionTests.cs @@ -0,0 +1,94 @@ +using System.Reflection; +using UnityEngine; +using ExitAction = S1API.PhoneApp.ExitAction; +using PhoneAppBase = S1API.PhoneApp.PhoneApp; + +namespace S1API.Tests.PhoneApp; + +public sealed class ExitActionTests +{ + [Fact] + public void UsedForwardsReadsAndWritesToTheNativeAdapter() + { + bool nativeUsed = false; + var exit = new ExitAction( + () => nativeUsed, + used => nativeUsed = used); + + Assert.False(exit.Used); + + exit.Used = true; + + Assert.True(nativeUsed); + Assert.True(exit.Used); + } + + [Fact] + public void ConstructorRejectsMissingNativeAccessors() + { + Assert.Throws(() => + new ExitAction(null!, _ => { })); + Assert.Throws(() => + new ExitAction(() => false, null!)); + } + + [Fact] + public void PhoneAppExitUsesOnlyTheS1ApiOwnedActionType() + { + MethodInfo exit = typeof(PhoneAppBase).GetMethod( + nameof(PhoneAppBase.Exit), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [typeof(ExitAction)], + modifiers: null)!; + + Assert.NotNull(exit); + Assert.True(exit.IsVirtual); + Assert.False(exit.IsFinal); + Assert.Equal(typeof(void), exit.ReturnType); + + ParameterInfo parameter = Assert.Single(exit.GetParameters()); + Assert.Equal("exit", parameter.Name); + Assert.Equal("S1API", parameter.ParameterType.Assembly.GetName().Name); + Assert.Equal("S1API.PhoneApp.ExitAction", parameter.ParameterType.FullName); + } + + [Fact] + public void ExitActionCannotBeConstructedByMods() + { + ConstructorInfo[] publicConstructors = typeof(ExitAction).GetConstructors( + BindingFlags.Instance | BindingFlags.Public); + + Assert.Empty(publicConstructors); + } + + [Fact] + public void ModsCanOverrideExitUsingTheS1ApiOwnedAction() + { + MethodInfo exit = typeof(ExitOverrideCompileFixture).GetMethod( + nameof(PhoneAppBase.Exit), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [typeof(ExitAction)], + modifiers: null)!; + + Assert.Equal(typeof(ExitOverrideCompileFixture), exit.DeclaringType); + } + + private sealed class ExitOverrideCompileFixture : PhoneAppBase + { + protected override string AppName => "exit-contract"; + protected override string AppTitle => "Exit Contract"; + protected override string IconLabel => "Exit"; + protected override string IconFileName => "exit.png"; + + protected override void OnCreatedUI(GameObject container) + { + } + + public override void Exit(ExitAction exit) + { + exit.Used = true; + } + } +} diff --git a/S1API/PhoneApp/ExitAction.cs b/S1API/PhoneApp/ExitAction.cs new file mode 100644 index 00000000..a6a331af --- /dev/null +++ b/S1API/PhoneApp/ExitAction.cs @@ -0,0 +1,31 @@ +using System; + +namespace S1API.PhoneApp +{ + /// + /// Represents a request to exit the active phone application without exposing + /// runtime-specific Schedule One types. + /// + public sealed class ExitAction + { + private readonly Func _getUsed; + private readonly Action _setUsed; + + internal ExitAction(Func getUsed, Action setUsed) + { + _getUsed = getUsed ?? throw new ArgumentNullException(nameof(getUsed)); + _setUsed = setUsed ?? throw new ArgumentNullException(nameof(setUsed)); + } + + /// + /// Gets or sets whether another listener has handled the exit request. + /// Set this to after handling the request to prevent + /// lower-priority listeners from processing it again. + /// + public bool Used + { + get => _getUsed(); + set => _setUsed(value); + } + } +} diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index 83c77ab4..f892692f 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -15,6 +15,7 @@ using MelonLoader.Utils; using Il2CppInterop.Runtime; using S1GameInput = Il2CppScheduleOne.GameInput; +using S1ExitAction = Il2CppScheduleOne.ExitAction; #elif MONOMELON using ScheduleOne.UI; using ScheduleOne.DevUtilities; @@ -22,6 +23,7 @@ using ScheduleOne; using MelonLoader.Utils; using S1GameInput = ScheduleOne.GameInput; +using S1ExitAction = ScheduleOne.ExitAction; #endif namespace S1API.PhoneApp { @@ -218,8 +220,10 @@ protected override void OnDestroyed() } /// - /// Handles exit/home button functionality. Called when user presses escape or home. + /// Handles exit/home button functionality without exposing runtime-specific game types. + /// Called when the user presses escape or home. /// + /// The cross-runtime exit request. public virtual void Exit(ExitAction exit) { if (!exit.Used && IsOpen() && Phone.InstanceExists && Phone.Instance.IsOpen) @@ -285,9 +289,9 @@ internal void SpawnUI(HomeScreen homeScreenInstance) // Create IL2CPP-safe delegate instance #if IL2CPPMELON - _exitDelegate = DelegateSupport.ConvertDelegate(new System.Action(Exit)); + _exitDelegate = DelegateSupport.ConvertDelegate(new System.Action(HandleNativeExit)); #else - _exitDelegate = new S1GameInput.ExitDelegate(Exit); + _exitDelegate = new S1GameInput.ExitDelegate(HandleNativeExit); #endif GameInput.RegisterExitListener(_exitDelegate, 1); @@ -297,6 +301,13 @@ internal void SpawnUI(HomeScreen homeScreenInstance) } } + private void HandleNativeExit(S1ExitAction exit) + { + Exit(new ExitAction( + () => exit.Used, + used => exit.Used = used)); + } + /// /// Creates or modifies the application icon displayed on the in-game phone's home screen. /// This method clones an existing icon, updates its label, and changes its image based on the provided file name. diff --git a/S1API/docs/phone-app.md b/S1API/docs/phone-app.md index 17a275f3..e181c745 100644 --- a/S1API/docs/phone-app.md +++ b/S1API/docs/phone-app.md @@ -8,7 +8,30 @@ Apps integrate with the native Home Screen, spawn icons, and manage open/close s - Derive from `PhoneApp` - Do not manually register; S1API auto-discovers `PhoneApp` subclasses when the phone `HomeScreen` starts - Implement `OnCreatedUI(GameObject container)` to build your UI -- Optionally override `OnPhoneClosed()` and `Exit(ExitAction exit)` for UX +- Optionally override `OnPhoneClosed()` and `Exit(S1API.PhoneApp.ExitAction exit)` for UX + +`S1API.PhoneApp.ExitAction` is a cross-runtime wrapper. Its `Used` property is +forwarded to the active Mono or IL2CPP game action, so phone apps do not need to +reference either native Schedule One type. + +### Migrating from S1API 3.0.6 + +S1API 3.0.6 exposed `ScheduleOne.DevUtilities.ExitAction` directly. Schedule I +0.4.6f11 moved that native type and made the old signature impossible to retain. +Change phone-app overrides to use the S1API-owned wrapper: + +```csharp +public override void Exit(S1API.PhoneApp.ExitAction exit) +{ + if (!exit.Used) + { + exit.Used = true; + // Close or reset custom UI state here. + } +} +``` + +This is the only intentional public signature exception in the 3.1.0 promotion. ## Minimal example From 66a298fd2437e1424c50684f9436f7e1623af6aa Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 22:56:22 -0700 Subject: [PATCH 004/147] fix(products): serialize icons and validate mixing profiles Coordinate loose-product and packaging captures through one FIFO render-rig lease held through a settled frame. Keep multiplayer clients gated when their manifest contains mixing profiles without descriptor-backed products. --- ...stomProductManifestRuntimeContractTests.cs | 36 ++++++++ .../ProductIconRenderRigArbiterTests.cs | 51 ++++++++++ .../Products/CustomProductManifestRuntime.cs | 13 ++- .../CustomProductPresentationRuntime.cs | 30 ++++++ .../Products/ProductIconRenderRigArbiter.cs | 92 +++++++++++++++++++ .../ProductPackagingContentRuntime.cs | 80 +++++++++------- 6 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs create mode 100644 S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs create mode 100644 S1API/Internal/Products/ProductIconRenderRigArbiter.cs diff --git a/S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs b/S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs new file mode 100644 index 00000000..60f10f83 --- /dev/null +++ b/S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs @@ -0,0 +1,36 @@ +using S1API.Internal.Products; + +namespace S1API.Tests.Products; + +public sealed class CustomProductManifestRuntimeContractTests +{ + [Fact] + public void EmptyManifestDoesNotRequireValidation() + { + Assert.False(CustomProductManifestRuntime.RequiresValidation( + new CustomProductManifestData())); + } + + [Fact] + public void DescriptorBackedProductRequiresValidation() + { + var manifest = new CustomProductManifestData + { + Entries = [new CustomProductManifestEntryData()] + }; + + Assert.True(CustomProductManifestRuntime.RequiresValidation(manifest)); + } + + [Fact] + public void MixingOnlyManifestRequiresValidation() + { + var manifest = new CustomProductManifestData + { + MixingProfiles = + [new CustomProductMixingProfileManifestEntryData()] + }; + + Assert.True(CustomProductManifestRuntime.RequiresValidation(manifest)); + } +} diff --git a/S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs b/S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs new file mode 100644 index 00000000..ebd6943a --- /dev/null +++ b/S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs @@ -0,0 +1,51 @@ +using S1API.Internal.Products; + +namespace S1API.Tests.Products; + +public sealed class ProductIconRenderRigArbiterTests : IDisposable +{ + public ProductIconRenderRigArbiterTests() + { + ProductIconRenderRigArbiter.ResetForTesting(); + } + + [Fact] + public void CaptureLeasesAreGrantedOneAtATimeInQueueOrder() + { + ProductIconRenderRigArbiter.CaptureLease first = + ProductIconRenderRigArbiter.Enqueue(); + ProductIconRenderRigArbiter.CaptureLease second = + ProductIconRenderRigArbiter.Enqueue(); + + Assert.False(ProductIconRenderRigArbiter.TryAcquire(second)); + Assert.True(ProductIconRenderRigArbiter.TryAcquire(first)); + Assert.False(ProductIconRenderRigArbiter.TryAcquire(second)); + + ProductIconRenderRigArbiter.Release(first); + + Assert.True(ProductIconRenderRigArbiter.TryAcquire(second)); + } + + [Fact] + public void CancellingAWaitingLeaseUnblocksTheNextCapture() + { + ProductIconRenderRigArbiter.CaptureLease first = + ProductIconRenderRigArbiter.Enqueue(); + ProductIconRenderRigArbiter.CaptureLease cancelled = + ProductIconRenderRigArbiter.Enqueue(); + ProductIconRenderRigArbiter.CaptureLease last = + ProductIconRenderRigArbiter.Enqueue(); + + Assert.True(ProductIconRenderRigArbiter.TryAcquire(first)); + ProductIconRenderRigArbiter.Cancel(cancelled); + ProductIconRenderRigArbiter.Release(first); + + Assert.False(ProductIconRenderRigArbiter.TryAcquire(cancelled)); + Assert.True(ProductIconRenderRigArbiter.TryAcquire(last)); + } + + public void Dispose() + { + ProductIconRenderRigArbiter.ResetForTesting(); + } +} diff --git a/S1API/Internal/Products/CustomProductManifestRuntime.cs b/S1API/Internal/Products/CustomProductManifestRuntime.cs index f8f15557..277518a1 100644 --- a/S1API/Internal/Products/CustomProductManifestRuntime.cs +++ b/S1API/Internal/Products/CustomProductManifestRuntime.cs @@ -150,8 +150,7 @@ internal static void FinalizeHostManifestAfterDescriptorRestore() _hostPayload = manifest.Serialize(_sessionId); _hostHash = manifest.CompatibilityHash; _hostEntryCount = manifest.Entries.Length; - _hostRequiresValidation = manifest.Entries.Length != 0 || - manifest.MixingProfiles.Length != 0; + _hostRequiresValidation = RequiresValidation(manifest); } catch (Exception exception) { @@ -198,8 +197,7 @@ internal static void RefreshHostManifestIfReady() _hostPayload = manifest.Serialize(_sessionId); _hostHash = manifest.CompatibilityHash; _hostEntryCount = manifest.Entries.Length; - _hostRequiresValidation = manifest.Entries.Length != 0 || - manifest.MixingProfiles.Length != 0; + _hostRequiresValidation = RequiresValidation(manifest); Info("host manifest refreshed after dynamic custom-product registration; entries=" + _hostEntryCount); } @@ -754,7 +752,7 @@ private static void OnClientDefinitionsReady() return; _localClientManifest = localManifest; _clientDefinitionsReady = true; - ClientGate.Begin(localManifest.Entries.Length != 0); + ClientGate.Begin(RequiresValidation(localManifest)); pending = _pendingClientManifest; _pendingClientManifest = null; } @@ -765,6 +763,11 @@ private static void OnClientDefinitionsReady() ProcessManifest(pending); } + internal static bool RequiresValidation( + CustomProductManifestData manifest) => + manifest.Entries.Length != 0 || + manifest.MixingProfiles.Length != 0; + private static void ProcessManifest(CustomProductManifestData manifest) { CustomProductManifestData localManifest; diff --git a/S1API/Internal/Products/CustomProductPresentationRuntime.cs b/S1API/Internal/Products/CustomProductPresentationRuntime.cs index b6b96f3e..8499a719 100644 --- a/S1API/Internal/Products/CustomProductPresentationRuntime.cs +++ b/S1API/Internal/Products/CustomProductPresentationRuntime.cs @@ -498,6 +498,7 @@ private static IEnumerator ProcessGeneratedIconQueue() } CustomProductPresentationState state = request.State; + ProductIconRenderRigArbiter.CaptureLease? renderLease = null; try { if (!state.IsGeneratedIconPending || @@ -525,6 +526,25 @@ private static IEnumerator ProcessGeneratedIconQueue() continue; } + renderLease = ProductIconRenderRigArbiter.Enqueue(); + while (!ProductIconRenderRigArbiter.TryAcquire(renderLease)) + { + if (!state.IsGeneratedIconPending || + !ReferenceEquals( + state.AppliedRegistration, + request.Registration)) + { + ProductIconRenderRigArbiter.Cancel(renderLease); + renderLease = null; + break; + } + + yield return null; + } + + if (renderLease == null) + continue; + const int maxRetries = 30; string lastError = "the native renderer returned no visible pixels"; for (int attempt = 0; attempt <= maxRetries; attempt++) @@ -556,9 +576,19 @@ private static IEnumerator ProcessGeneratedIconQueue() if (state.IsGeneratedIconPending) LogGeneratedIconFailure(request, lastError); + + // IconFactory restores the shared rig synchronously. Keep + // ownership through a full settled frame so another subject + // cannot capture a transition between the outgoing model and + // the next queued model. + yield return null; + yield return new WaitForEndOfFrame(); } finally { + if (renderLease != null) + ProductIconRenderRigArbiter.Release(renderLease); + state.IsGeneratedIconQueued = false; if (state.IsGeneratedIconPending && state.AppliedRegistration != null && diff --git a/S1API/Internal/Products/ProductIconRenderRigArbiter.cs b/S1API/Internal/Products/ProductIconRenderRigArbiter.cs new file mode 100644 index 00000000..ef7c55cd --- /dev/null +++ b/S1API/Internal/Products/ProductIconRenderRigArbiter.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; + +namespace S1API.Internal.Products +{ + /// + /// INTERNAL: Serializes queued captures that share the native item-icon render rig. + /// + internal static class ProductIconRenderRigArbiter + { + private static readonly object Gate = new object(); + private static readonly Queue Pending = + new Queue(); + private static CaptureLease? _owner; + + internal sealed class CaptureLease + { + internal bool Cancelled { get; set; } + } + + internal static CaptureLease Enqueue() + { + lock (Gate) + { + var lease = new CaptureLease(); + Pending.Enqueue(lease); + return lease; + } + } + + internal static bool TryAcquire(CaptureLease lease) + { + lock (Gate) + { + if (lease.Cancelled) + return false; + if (ReferenceEquals(_owner, lease)) + return true; + if (_owner != null) + return false; + + RemoveCancelledHead(); + if (Pending.Count == 0 || + !ReferenceEquals(Pending.Peek(), lease)) + { + return false; + } + + _owner = Pending.Dequeue(); + return true; + } + } + + internal static void Release(CaptureLease lease) + { + lock (Gate) + { + if (ReferenceEquals(_owner, lease)) + _owner = null; + else + lease.Cancelled = true; + + RemoveCancelledHead(); + } + } + + internal static void Cancel(CaptureLease lease) + { + lock (Gate) + { + lease.Cancelled = true; + if (ReferenceEquals(_owner, lease)) + _owner = null; + RemoveCancelledHead(); + } + } + + internal static void ResetForTesting() + { + lock (Gate) + { + Pending.Clear(); + _owner = null; + } + } + + private static void RemoveCancelledHead() + { + while (Pending.Count != 0 && Pending.Peek().Cancelled) + Pending.Dequeue(); + } + } +} diff --git a/S1API/Internal/Products/ProductPackagingContentRuntime.cs b/S1API/Internal/Products/ProductPackagingContentRuntime.cs index 93e24817..f5e324d5 100644 --- a/S1API/Internal/Products/ProductPackagingContentRuntime.cs +++ b/S1API/Internal/Products/ProductPackagingContentRuntime.cs @@ -562,50 +562,66 @@ private static IEnumerator ProcessGeneratedIconQueue(int generation) continue; } - yield return null; - yield return new WaitForEndOfFrame(); - - if (generation != _iconQueueGeneration) - yield break; - + ProductIconRenderRigArbiter.CaptureLease renderLease = + ProductIconRenderRigArbiter.Enqueue(); try { - S1DevUtilities.IconGenerator generator = - IconFactory.S1IconGenerator; - bool iconCached = false; - lock (IconGate) + while (!ProductIconRenderRigArbiter.TryAcquire(renderLease)) + { + if (generation != _iconQueueGeneration) + yield break; + + yield return null; + } + + yield return null; + yield return new WaitForEndOfFrame(); + + if (generation != _iconQueueGeneration) + yield break; + + try { - EnsureIconCacheMatches(generator); - if (!GeneratedIcons.ContainsKey(registration.Key) && - TryGeneratePackagingIconCore( - generator, - registration, - out Texture2D? texture) && - texture != null) + S1DevUtilities.IconGenerator generator = + IconFactory.S1IconGenerator; + bool iconCached = false; + lock (IconGate) { - iconCached = - TryCacheGeneratedIcon(registration, texture); + EnsureIconCacheMatches(generator); + if (!GeneratedIcons.ContainsKey(registration.Key) && + TryGeneratePackagingIconCore( + generator, + registration, + out Texture2D? texture) && + texture != null) + { + iconCached = + TryCacheGeneratedIcon(registration, texture); + } } + + if (iconCached) + RefreshMatchingItemUis(registration); + } + catch (Exception exception) + { + LogFailureOnce( + registration, + "composite icon", + exception.Message); } - if (iconCached) - RefreshMatchingItemUis(registration); - } - catch (Exception exception) - { - LogFailureOnce( - registration, - "composite icon", - exception.Message); + // TryGeneratePackagingIconCore restores the native rig in + // its finally block. Hold the lease through a full settled + // frame before the next queued subject can capture. + yield return null; + yield return new WaitForEndOfFrame(); } finally { + ProductIconRenderRigArbiter.Release(renderLease); CompleteQueuedIcon(registration.Key, generation); } - - // RuntimePreviewGenerator uses a shared render rig. Give its - // temporary model a frame to leave the rig before the next pair. - yield return null; } } From 00c782aacf1dac3d265ea228ceaf758c523257a1 Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 23:25:02 -0700 Subject: [PATCH 005/147] fix(ci): route coverage updates through pull requests Generate coverage metadata as before, then publish it through a deterministic automation branch and pull request so maintained-branch protections remain enforced. --- .github/workflows/coverage.yml | 163 ++++++++++++++------------------- 1 file changed, 71 insertions(+), 92 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0eb020ac..c97982fc 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,6 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - name: Checkout S1API uses: actions/checkout@v4 @@ -220,76 +221,35 @@ jobs: echo "Covered Classes: $(jq -r '.classCoverage.covered' coverage-report.json) / $(jq -r '.classCoverage.total' coverage-report.json)" fi - - name: Update README Badge and Chart + - name: Generate README Badge and Chart Updates + id: coverage-files if: steps.verify-assemblies.outputs.has_assemblies == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable') run: | COVERAGE_CHANGED="${{ steps.coverage.outputs.coverage_changed }}" echo "Coverage changed status: $COVERAGE_CHANGED" - - # Configure git early for potential commits - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - + # Check if history was deduplicated (has changes) HISTORY_DEDUPLICATED=false if ! git diff --quiet tools/S1APICoverageAnalyzer/coverage-history.json; then echo "History file was deduplicated" HISTORY_DEDUPLICATED=true fi - - # If coverage hasn't changed, only commit history deduplication and chart update if needed - if [ "$COVERAGE_CHANGED" != "true" ]; then - echo "Coverage percentage unchanged - skipping badge update" - - if [ "$HISTORY_DEDUPLICATED" = true ]; then - echo "Updating chart with deduplicated history..." - - # Update coverage chart in README if chart file exists - if [ -f coverage-chart.md ]; then - echo "Updating coverage chart in README..." - - # Extract the chart image URL from coverage-chart.md (line 3) - CHART_URL=$(sed -n '3p' coverage-chart.md) - - # Find the line number with the existing chart in README - CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1) - - if [ -n "$CHART_LINE" ]; then - echo "Found chart at line $CHART_LINE, updating..." - # Replace the chart line - awk -v line="$CHART_LINE" -v new_chart="$CHART_URL" 'NR==line {print new_chart; next} {print}' README.md > README.md.tmp - mv README.md.tmp README.md - else - echo "Chart line not found in README" - fi - fi - - echo "Committing deduplicated history and updated chart..." - git add README.md tools/S1APICoverageAnalyzer/coverage-history.json - git commit -m "chore: deduplicate coverage history and update chart [skip ci]" - git push - else - echo "No changes to commit" - fi - - exit 0 - fi - + # Coverage changed - update README badge and chart - if [ -f coverage-badge.md ]; then + if [ "$COVERAGE_CHANGED" = "true" ] && [ -f coverage-badge.md ]; then # Read the new badge markdown (trim whitespace) NEW_BADGE=$(cat coverage-badge.md | tr -d '\n\r') - + # Replace the link to point to the GitHub Actions workflow # Extract the badge image URL and replace the link URL WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/workflows/coverage.yml" NEW_BADGE=$(echo "$NEW_BADGE" | sed "s|](docs/coverage-report.json)|]($WORKFLOW_URL)|g") - + echo "New badge: $NEW_BADGE" - + # Find the line number with the API Coverage badge in README BADGE_LINE=$(grep -n "\[!\[API Coverage\]" README.md | head -1 | cut -d: -f1) - + if [ -n "$BADGE_LINE" ]; then echo "Found API Coverage badge at line $BADGE_LINE, updating..." # Update the badge line in README.md @@ -299,51 +259,70 @@ jobs: else echo "Warning: API Coverage badge not found in README.md" fi - - # Update coverage chart in README if chart file exists - if [ -f coverage-chart.md ]; then - echo "Updating coverage chart in README..." - - # Extract the chart image URL from coverage-chart.md (line 3) - CHART_URL=$(sed -n '3p' coverage-chart.md) - - # Find the line number with the existing chart in README - CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1) - - if [ -n "$CHART_LINE" ]; then - echo "Found chart at line $CHART_LINE, updating..." - # Replace the chart line - awk -v line="$CHART_LINE" -v new_chart="$CHART_URL" 'NR==line {print new_chart; next} {print}' README.md > README.md.tmp - mv README.md.tmp README.md - else - echo "Chart line not found in README" - fi - fi - - # Check if there are changes to commit - CHANGES_EXIST=false - - if ! git diff --quiet README.md; then - echo "README.md has changes" - CHANGES_EXIST=true - fi - - if ! git diff --quiet tools/S1APICoverageAnalyzer/coverage-history.json; then - echo "coverage-history.json has changes" - CHANGES_EXIST=true - fi - - if [ "$CHANGES_EXIST" = true ]; then - echo "Committing changes..." - git diff README.md - git add README.md tools/S1APICoverageAnalyzer/coverage-history.json - git commit -m "chore: update API coverage badge and history [skip ci]" - git push + else + echo "Coverage percentage unchanged or badge file unavailable - skipping badge update" + fi + + # Regenerate the chart when coverage or the normalized history changed. + if { [ "$COVERAGE_CHANGED" = "true" ] || [ "$HISTORY_DEDUPLICATED" = "true" ]; } && [ -f coverage-chart.md ]; then + echo "Updating coverage chart in README..." + CHART_URL=$(sed -n '3p' coverage-chart.md) + CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1) + + if [ -n "$CHART_LINE" ]; then + echo "Found chart at line $CHART_LINE, updating..." + awk -v line="$CHART_LINE" -v new_chart="$CHART_URL" 'NR==line {print new_chart; next} {print}' README.md > README.md.tmp + mv README.md.tmp README.md else - echo "No changes to commit" + echo "Chart line not found in README" fi + fi + + if git diff --quiet -- README.md tools/S1APICoverageAnalyzer/coverage-history.json; then + echo "No coverage files changed" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Coverage files changed" + git diff -- README.md tools/S1APICoverageAnalyzer/coverage-history.json + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Open Coverage Update Pull Request + if: steps.coverage-files.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_BRANCH: ${{ github.ref_name }} + UPDATE_BRANCH: automation/coverage-${{ github.ref_name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md tools/S1APICoverageAnalyzer/coverage-history.json + git commit -m "chore: update API coverage badge and history" + + REMOTE_SHA=$(git ls-remote --heads origin "refs/heads/$UPDATE_BRANCH" | cut -f1) + if [ -n "$REMOTE_SHA" ]; then + git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$REMOTE_SHA" origin "HEAD:$UPDATE_BRANCH" + else + git push origin "HEAD:$UPDATE_BRANCH" + fi + + EXISTING_PR=$(gh pr list \ + --repo "${{ github.repository }}" \ + --base "$BASE_BRANCH" \ + --head "$UPDATE_BRANCH" \ + --state open \ + --json url \ + --jq '.[0].url') + + if [ -n "$EXISTING_PR" ]; then + echo "Updated existing coverage pull request: $EXISTING_PR" else - echo "coverage-badge.md not found, skipping README update" + gh pr create \ + --repo "${{ github.repository }}" \ + --base "$BASE_BRANCH" \ + --head "$UPDATE_BRANCH" \ + --title "chore: update API coverage badge and history" \ + --body "Automated coverage metadata update generated by [workflow run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." fi # Save cache even if build fails (to enable beta cache priming) From ed08be4ce4ae7f6af568da5a4db7ec78188e645d Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 23:32:34 -0700 Subject: [PATCH 006/147] fix(ci): dispatch checks for coverage pull requests GitHub suppresses pull_request workflow events for pull requests created with GITHUB_TOKEN. Dispatch the required documentation workflow on the generated coverage branch so protected-branch checks still run. --- .github/workflows/coverage.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c97982fc..f3cd784c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,6 +20,7 @@ jobs: coverage: runs-on: ubuntu-latest permissions: + actions: write contents: write pull-requests: write steps: @@ -325,6 +326,13 @@ jobs: --body "Automated coverage metadata update generated by [workflow run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." fi + # Pull requests created with GITHUB_TOKEN do not emit pull_request + # workflow events. Dispatch the required documentation check against + # the generated head commit so branch protection remains effective. + gh workflow run docs.yml \ + --repo "${{ github.repository }}" \ + --ref "$UPDATE_BRANCH" + # Save cache even if build fails (to enable beta cache priming) # Save on PR events AND on pushes to main branches (to update cache after assembly updates) - name: Save Game Assemblies to Cache From e55bd38e8bfbdf466f42917367b884cde67692a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Aug 2026 06:38:07 +0000 Subject: [PATCH 007/147] chore: update API coverage badge and history --- README.md | 4 +- .../coverage-history.json | 44 ++++++++++++------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 168f442b..079f402f 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ The goal is to provide a standard place for common functionalities so you can fo Track S1API's progress in wrapping Schedule One's game types: -[![API Coverage](https://img.shields.io/badge/API%20Coverage-31.6%25-orange)](https://github.com/ifBars/S1API/actions/workflows/coverage.yml) +[![API Coverage](https://img.shields.io/badge/API%20Coverage-32.0%25-orange)](https://github.com/ifBars/S1API/actions/workflows/coverage.yml) -![Coverage Chart](https://quickchart.io/chart?c=%7B%22type%22%3A%22line%22%2C%22data%22%3A%7B%22labels%22%3A%5B%222025-12-30%22%2C%222025-12-30%22%2C%222026-01-02%22%2C%222026-01-02%22%2C%222026-01-03%22%2C%222026-01-08%22%2C%222026-01-08%22%2C%222026-01-11%22%2C%222026-01-27%22%2C%222026-01-28%22%2C%222026-01-28%22%2C%222026-01-29%22%2C%222026-02-03%22%2C%222026-02-10%22%2C%222026-02-20%22%2C%222026-04-02%22%2C%222026-06-04%22%2C%222026-06-09%22%2C%222026-06-21%22%2C%222026-07-06%22%2C%222026-07-06%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Class%20Coverage%20%25%22%2C%22data%22%3A%5B25%2C27.491785323110623%2C28.039430449069002%2C28.148959474260675%2C28.258488499452355%2C27.854855923159015%2C27.628865979381445%2C27.938144329896907%2C28.24742268041237%2C28.45360824742268%2C29.07216494845361%2C29.175257731958766%2C29.303278688524593%2C29.815573770491806%2C30.43032786885246%2C30.417495029821072%2C30.61630218687873%2C30.91451292246521%2C31.21272365805169%2C31.610337972166995%2C31.610337972166995%5D%2C%22borderColor%22%3A%22rgb%2875%2C%20192%2C%20192%29%22%2C%22backgroundColor%22%3A%22rgba%2875%2C%20192%2C%20192%2C%200.1%29%22%2C%22fill%22%3Afalse%2C%22tension%22%3A0.1%7D%5D%7D%2C%22options%22%3A%7B%22responsive%22%3Atrue%2C%22plugins%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22S1API%20Coverage%20Over%20Time%22%7D%2C%22legend%22%3A%7B%22display%22%3Atrue%2C%22position%22%3A%22top%22%7D%7D%2C%22scales%22%3A%7B%22y%22%3A%7B%22beginAtZero%22%3Atrue%2C%22max%22%3A100%2C%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Coverage%20%25%22%7D%7D%2C%22x%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Date%22%7D%7D%7D%7D%7D&width=800&height=400) +![Coverage Chart](https://quickchart.io/chart?c=%7B%22type%22%3A%22line%22%2C%22data%22%3A%7B%22labels%22%3A%5B%222025-12-30%22%2C%222025-12-30%22%2C%222026-01-02%22%2C%222026-01-02%22%2C%222026-01-03%22%2C%222026-01-08%22%2C%222026-01-08%22%2C%222026-01-11%22%2C%222026-01-27%22%2C%222026-01-28%22%2C%222026-01-28%22%2C%222026-01-29%22%2C%222026-02-03%22%2C%222026-02-10%22%2C%222026-02-20%22%2C%222026-04-02%22%2C%222026-06-04%22%2C%222026-06-09%22%2C%222026-06-21%22%2C%222026-07-06%22%2C%222026-08-01%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Class%20Coverage%20%25%22%2C%22data%22%3A%5B25%2C27.491785323110623%2C28.039430449069002%2C28.148959474260675%2C28.258488499452355%2C27.854855923159015%2C27.628865979381445%2C27.938144329896907%2C28.24742268041237%2C28.45360824742268%2C29.07216494845361%2C29.175257731958766%2C29.303278688524593%2C29.815573770491806%2C30.43032786885246%2C30.417495029821072%2C30.61630218687873%2C30.91451292246521%2C31.21272365805169%2C31.610337972166995%2C32.01376936316696%5D%2C%22borderColor%22%3A%22rgb%2875%2C%20192%2C%20192%29%22%2C%22backgroundColor%22%3A%22rgba%2875%2C%20192%2C%20192%2C%200.1%29%22%2C%22fill%22%3Afalse%2C%22tension%22%3A0.1%7D%5D%7D%2C%22options%22%3A%7B%22responsive%22%3Atrue%2C%22plugins%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22S1API%20Coverage%20Over%20Time%22%7D%2C%22legend%22%3A%7B%22display%22%3Atrue%2C%22position%22%3A%22top%22%7D%7D%2C%22scales%22%3A%7B%22y%22%3A%7B%22beginAtZero%22%3Atrue%2C%22max%22%3A100%2C%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Coverage%20%25%22%7D%7D%2C%22x%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Date%22%7D%7D%7D%7D%7D&width=800&height=400) *View detailed coverage reports in the [Coverage Analysis workflow](https://github.com/ifBars/S1API/actions/workflows/coverage.yml)* diff --git a/tools/S1APICoverageAnalyzer/coverage-history.json b/tools/S1APICoverageAnalyzer/coverage-history.json index a69b0352..1a27f71f 100644 --- a/tools/S1APICoverageAnalyzer/coverage-history.json +++ b/tools/S1APICoverageAnalyzer/coverage-history.json @@ -309,34 +309,46 @@ "note": null }, { - "timestamp": "2026-07-06T04:06:17.7808193Z", - "classCoveragePercentage": 31.610337972166995, + "timestamp": "2026-08-01T06:38:06.4190254Z", + "classCoveragePercentage": 32.01376936316696, "memberCoveragePercentage": 0, - "totalClasses": 1006, - "coveredClasses": 318, - "totalMembers": 25377, + "totalClasses": 1162, + "coveredClasses": 372, + "totalMembers": 25992, "coveredMembers": 0, - "excludedClasses": 1176, + "excludedClasses": 1207, "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", - "gameAssemblyHash": "AF680F5F4219D455D790B33773AA471BA9797157BFEF71252D016F5EDBE0FC7D", + "gameAssemblyHash": "5689194244A2C00DA4D78EB5B010AADB85F7BF4D51CB5A6918162E7598860B62", "analyzerVersion": "1.0.0.0", - "events": [], + "events": [ + { + "type": 1, + "description": "Game updated: \u002B156 types (\u002B15.5%)", + "details": "Hash: AF680F5F \u2192 56891942" + } + ], "note": null } ], "latestEntry": { - "timestamp": "2026-07-06T04:06:17.7808193Z", - "classCoveragePercentage": 31.610337972166995, + "timestamp": "2026-08-01T06:38:06.4190254Z", + "classCoveragePercentage": 32.01376936316696, "memberCoveragePercentage": 0, - "totalClasses": 1006, - "coveredClasses": 318, - "totalMembers": 25377, + "totalClasses": 1162, + "coveredClasses": 372, + "totalMembers": 25992, "coveredMembers": 0, - "excludedClasses": 1176, + "excludedClasses": 1207, "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", - "gameAssemblyHash": "AF680F5F4219D455D790B33773AA471BA9797157BFEF71252D016F5EDBE0FC7D", + "gameAssemblyHash": "5689194244A2C00DA4D78EB5B010AADB85F7BF4D51CB5A6918162E7598860B62", "analyzerVersion": "1.0.0.0", - "events": [], + "events": [ + { + "type": 1, + "description": "Game updated: \u002B156 types (\u002B15.5%)", + "details": "Hash: AF680F5F \u2192 56891942" + } + ], "note": null } } \ No newline at end of file From 0cb00b3145e2055c59f19517f4df11de7faf54bc Mon Sep 17 00:00:00 2001 From: ifBars Date: Fri, 31 Jul 2026 23:53:48 -0700 Subject: [PATCH 008/147] fix(ci): stop coverage metadata update loops Ignore metadata-only pushes and discard analyzer timestamp churn when measured coverage is unchanged, while retaining pull-request checks for real coverage updates. --- .github/workflows/coverage.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f3cd784c..3e141129 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,6 +3,9 @@ name: API Coverage Analysis on: push: branches: [ master, main, stable ] + paths-ignore: + - README.md + - tools/S1APICoverageAnalyzer/coverage-history.json pull_request: branches: [ master, main, stable ] workflow_dispatch: @@ -229,15 +232,17 @@ jobs: COVERAGE_CHANGED="${{ steps.coverage.outputs.coverage_changed }}" echo "Coverage changed status: $COVERAGE_CHANGED" - # Check if history was deduplicated (has changes) - HISTORY_DEDUPLICATED=false - if ! git diff --quiet tools/S1APICoverageAnalyzer/coverage-history.json; then - echo "History file was deduplicated" - HISTORY_DEDUPLICATED=true + # The analyzer refreshes the latest history timestamp on every run. + # Discard that metadata churn unless the measured coverage changed. + if [ "$COVERAGE_CHANGED" != "true" ]; then + echo "Coverage percentage unchanged - discarding generated metadata churn" + git restore -- README.md tools/S1APICoverageAnalyzer/coverage-history.json + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 fi # Coverage changed - update README badge and chart - if [ "$COVERAGE_CHANGED" = "true" ] && [ -f coverage-badge.md ]; then + if [ -f coverage-badge.md ]; then # Read the new badge markdown (trim whitespace) NEW_BADGE=$(cat coverage-badge.md | tr -d '\n\r') @@ -261,11 +266,10 @@ jobs: echo "Warning: API Coverage badge not found in README.md" fi else - echo "Coverage percentage unchanged or badge file unavailable - skipping badge update" + echo "Coverage badge file unavailable - skipping badge update" fi - # Regenerate the chart when coverage or the normalized history changed. - if { [ "$COVERAGE_CHANGED" = "true" ] || [ "$HISTORY_DEDUPLICATED" = "true" ]; } && [ -f coverage-chart.md ]; then + if [ -f coverage-chart.md ]; then echo "Updating coverage chart in README..." CHART_URL=$(sed -n '3p' coverage-chart.md) CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1) From 56d96f024db7e5987fe2e0d1de1b99fb0f854d70 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 1 Aug 2026 00:00:38 -0700 Subject: [PATCH 009/147] refactor(ci): remove coverage PR bootstrap dispatch The coverage bot's first pull request has passed the repository contributor-approval gate, so normal pull-request workflows now provide required checks without a duplicate manual documentation run. --- .github/workflows/coverage.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3e141129..0e1e3cae 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,7 +23,6 @@ jobs: coverage: runs-on: ubuntu-latest permissions: - actions: write contents: write pull-requests: write steps: @@ -330,13 +329,6 @@ jobs: --body "Automated coverage metadata update generated by [workflow run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." fi - # Pull requests created with GITHUB_TOKEN do not emit pull_request - # workflow events. Dispatch the required documentation check against - # the generated head commit so branch protection remains effective. - gh workflow run docs.yml \ - --repo "${{ github.repository }}" \ - --ref "$UPDATE_BRANCH" - # Save cache even if build fails (to enable beta cache priming) # Save on PR events AND on pushes to main branches (to update cache after assembly updates) - name: Save Game Assemblies to Cache From 7067ee5931ee833e27da0dbf27666c74c11f9508 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 1 Aug 2026 02:58:36 -0700 Subject: [PATCH 010/147] feat(Trash): add station trash prefab registration --- .../Trash/TrashApiCompatibilityTests.cs | 31 +++ .../Storable/StorableItemDefinitionBuilder.cs | 74 +++++++ S1API/Trash/TrashManager.cs | 184 +++++++++++++++++- 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 S1API.Tests/Trash/TrashApiCompatibilityTests.cs diff --git a/S1API.Tests/Trash/TrashApiCompatibilityTests.cs b/S1API.Tests/Trash/TrashApiCompatibilityTests.cs new file mode 100644 index 00000000..558f23bd --- /dev/null +++ b/S1API.Tests/Trash/TrashApiCompatibilityTests.cs @@ -0,0 +1,31 @@ +using S1API.Items.Storable; +using UnityEngine; + +namespace S1API.Tests.Trash; + +public sealed class TrashApiCompatibilityTests +{ + [Fact] + public void TrashManagerExposesPrefabRegistration() + { + var method = typeof(global::S1API.Trash.TrashManager).GetMethod( + nameof(global::S1API.Trash.TrashManager.RegisterTrashPrefab), + new[] { typeof(string), typeof(GameObject), typeof(bool) }); + + Assert.NotNull(method); + Assert.Equal(typeof(GameObject), method!.ReturnType); + } + + [Fact] + public void StorableBuilderExposesDefaultAndExplicitTrashIds() + { + Assert.NotNull( + typeof(StorableItemDefinitionBuilder).GetMethod( + nameof(StorableItemDefinitionBuilder.WithTrashPrefab), + new[] { typeof(GameObject), typeof(bool) })); + Assert.NotNull( + typeof(StorableItemDefinitionBuilder).GetMethod( + nameof(StorableItemDefinitionBuilder.WithTrashPrefab), + new[] { typeof(string), typeof(GameObject), typeof(bool) })); + } +} diff --git a/S1API/Items/Storable/StorableItemDefinitionBuilder.cs b/S1API/Items/Storable/StorableItemDefinitionBuilder.cs index 5b878535..e27d1b52 100644 --- a/S1API/Items/Storable/StorableItemDefinitionBuilder.cs +++ b/S1API/Items/Storable/StorableItemDefinitionBuilder.cs @@ -5,6 +5,7 @@ using S1Registry = Il2CppScheduleOne.Registry; using S1StationFramework = Il2CppScheduleOne.StationFramework; using S1Storage = Il2CppScheduleOne.Storage; +using S1Trash = Il2CppScheduleOne.Trash; #elif MONOMELON using S1ItemFramework = ScheduleOne.ItemFramework; using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; @@ -12,6 +13,7 @@ using S1Registry = ScheduleOne.Registry; using S1StationFramework = ScheduleOne.StationFramework; using S1Storage = ScheduleOne.Storage; +using S1Trash = ScheduleOne.Trash; #endif using System; using System.Collections.Generic; @@ -99,6 +101,9 @@ private static GameObject? StationItemRoot protected readonly S1ItemFramework.StorableItemDefinition Definition; private readonly GameObject _storedItemPlaceholder; private bool _hasCustomStoredItem; + private GameObject? _trashPrefab; + private string? _trashId; + private bool _replaceExistingTrash; /// /// INTERNAL: Whether a custom StoredItem was assigned via . @@ -341,6 +346,54 @@ public TSelf WithoutStationItem() return Self; } + /// + /// Assigns and registers the trash prefab spawned after this station item is consumed. + /// + /// + /// The station item must be configured before . If no explicit trash ID is + /// supplied, S1API uses <item ID>_trash. All multiplayer clients must build the + /// same registration. + /// + /// Prefab containing a native TrashItem component. + /// Whether an existing trash registration may be replaced. + public TSelf WithTrashPrefab( + GameObject trashPrefab, + bool replaceExisting = false) + { + return WithTrashPrefab( + trashId: null, + trashPrefab, + replaceExisting); + } + + /// + /// Assigns and registers the trash prefab spawned after this station item is consumed. + /// + /// Stable trash ID used for spawning and persistence. + /// Prefab containing a native TrashItem component. + /// Whether an existing trash registration may be replaced. + public TSelf WithTrashPrefab( + string? trashId, + GameObject trashPrefab, + bool replaceExisting = false) + { + if (trashPrefab == null) + throw new ArgumentNullException(nameof(trashPrefab)); + if (trashPrefab.GetComponent() == null) + { + throw new ArgumentException( + "Trash prefab must have a TrashItem component.", + nameof(trashPrefab)); + } + if (trashId != null && string.IsNullOrWhiteSpace(trashId)) + throw new ArgumentException("Trash ID cannot be empty.", nameof(trashId)); + + _trashId = trashId; + _trashPrefab = trashPrefab; + _replaceExistingTrash = replaceExisting; + return Self; + } + /// /// Sets whether this item is available in the demo version of the game. /// @@ -393,6 +446,8 @@ protected virtual Storable.StorableItemDefinition Build() } } + ApplyTrashPrefab(); + // Register with the game's registry S1Registry.Instance.AddToRegistry(Definition); RuntimeItemDefinitionRegistry.Retain(Definition.ID, Definition); @@ -401,6 +456,25 @@ protected virtual Storable.StorableItemDefinition Build() return CreateWrapper(Definition); } + private void ApplyTrashPrefab() + { + if (_trashPrefab == null) + return; + if (Definition.StationItem == null) + { + throw new InvalidOperationException( + "A station item is required before configuring its trash prefab."); + } + + string trashId = _trashId ?? $"{Definition.ID}_trash"; + GameObject registered = global::S1API.Trash.TrashManager.RegisterTrashPrefab( + trashId, + _trashPrefab, + _replaceExistingTrash); + Definition.StationItem.TrashPrefab = + registered.GetComponent(); + } + /// /// INTERNAL: Builds and returns the raw game item definition without registering. /// Used internally by S1API. Modders should use instead. diff --git a/S1API/Trash/TrashManager.cs b/S1API/Trash/TrashManager.cs index ab808c2c..8d73dbde 100644 --- a/S1API/Trash/TrashManager.cs +++ b/S1API/Trash/TrashManager.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; +using S1API.Lifecycle; using UnityEngine; +using Object = UnityEngine.Object; #if (IL2CPPMELON) +using Il2CppInterop.Runtime.InteropTypes.Arrays; using S1Trash = Il2CppScheduleOne.Trash; #elif MONOMELON using S1Trash = ScheduleOne.Trash; @@ -14,6 +18,13 @@ namespace S1API.Trash /// public static class TrashManager { + private static readonly object RegistrationGate = new object(); + private static readonly Dictionary + RegisteredPrefabs = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private static GameObject? _prefabRoot; + private static bool _lifecycleSubscribed; + /// /// Maximum number of trash items allowed in the world (2000). /// @@ -48,8 +59,75 @@ public static class TrashManager /// The trash prefab GameObject, or null if not found. public static GameObject? GetTrashPrefab(string id) { - var prefab = S1Trash.TrashManager.Instance.GetTrashPrefab(id); - return prefab?.gameObject; + var manager = S1Trash.TrashManager.Instance; + var nativePrefab = manager != null + ? manager.GetTrashPrefab(id) + : null; + if (nativePrefab != null) + return nativePrefab.gameObject; + + lock (RegistrationGate) + { + return RegisteredPrefabs.TryGetValue(id, out var registered) + ? registered.gameObject + : null; + } + } + + /// + /// Registers a stable trash prefab that can be spawned by ID and referenced by station items. + /// + /// + /// The supplied prefab is cloned under an inactive, persistent cache root. Registrations are + /// reapplied after game loads so network trash spawning can resolve the same ID on every client. + /// All clients must register the same ID and prefab behavior for multiplayer consistency. + /// + /// Stable trash ID used for spawning and persistence. + /// Prefab containing a native TrashItem component. + /// Whether an existing registration with the same ID may be replaced. + /// The cached registered prefab. + public static GameObject RegisterTrashPrefab( + string id, + GameObject trashPrefab, + bool replaceExisting = false) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Trash ID is required.", nameof(id)); + if (trashPrefab == null) + throw new ArgumentNullException(nameof(trashPrefab)); + + var source = trashPrefab.GetComponent(); + if (source == null) + { + throw new ArgumentException( + "Trash prefab must have a TrashItem component.", + nameof(trashPrefab)); + } + + S1Trash.TrashItem cached; + lock (RegistrationGate) + { + if (RegisteredPrefabs.TryGetValue(id, out var existing) && + existing != null) + { + if (!replaceExisting) + return existing.gameObject; + + Object.Destroy(existing.gameObject); + } + + var root = GetOrCreatePrefabRoot(); + cached = Object.Instantiate(source, root.transform, false); + cached.name = $"S1API_Trash_{id}"; + cached.ID = id; + cached.gameObject.hideFlags = HideFlags.HideAndDontSave; + cached.gameObject.SetActive(true); + RegisteredPrefabs[id] = cached; + EnsureLifecycleSubscription(); + } + + ApplyRegistration(id, cached, replaceExisting: true); + return cached.gameObject; } /// @@ -61,5 +139,107 @@ public static class TrashManager var prefab = S1Trash.TrashManager.Instance.GetRandomGeneratableTrashPrefab(); return prefab?.gameObject; } + + private static void EnsureLifecycleSubscription() + { + if (_lifecycleSubscribed) + return; + + GameLifecycle.OnPreLoad += ApplyAllRegistrations; + GameLifecycle.OnLoadComplete += ApplyAllRegistrations; + _lifecycleSubscribed = true; + } + + private static void ApplyAllRegistrations() + { + KeyValuePair[] registrations; + lock (RegistrationGate) + { + registrations = + new KeyValuePair[RegisteredPrefabs.Count]; + int index = 0; + foreach (var registration in RegisteredPrefabs) + registrations[index++] = registration; + } + + foreach (var registration in registrations) + { + if (registration.Value != null) + { + ApplyRegistration( + registration.Key, + registration.Value, + replaceExisting: true); + } + } + } + + private static void ApplyRegistration( + string id, + S1Trash.TrashItem prefab, + bool replaceExisting) + { + var manager = S1Trash.TrashManager.Instance; + if (manager == null) + return; + +#if (IL2CPPMELON) + Il2CppReferenceArray? prefabs = manager.TrashPrefabs; + int count = prefabs?.Length ?? 0; + for (int index = 0; index < count; index++) + { + var existing = prefabs![index]; + if (!string.Equals(existing?.ID, id, StringComparison.OrdinalIgnoreCase)) + continue; + + if (replaceExisting) + { + prefabs[index] = prefab; + manager.TrashPrefabs = prefabs; + } + return; + } + + var expanded = new Il2CppReferenceArray(count + 1); + for (int index = 0; index < count; index++) + expanded[index] = prefabs![index]; + expanded[count] = prefab; + manager.TrashPrefabs = expanded; +#else + S1Trash.TrashItem[] prefabs = + manager.TrashPrefabs ?? Array.Empty(); + for (int index = 0; index < prefabs.Length; index++) + { + var existing = prefabs[index]; + if (!string.Equals(existing?.ID, id, StringComparison.OrdinalIgnoreCase)) + continue; + + if (replaceExisting) + { + prefabs[index] = prefab; + manager.TrashPrefabs = prefabs; + } + return; + } + + Array.Resize(ref prefabs, prefabs.Length + 1); + prefabs[prefabs.Length - 1] = prefab; + manager.TrashPrefabs = prefabs; +#endif + } + + private static GameObject GetOrCreatePrefabRoot() + { + if (_prefabRoot != null) + return _prefabRoot; + + _prefabRoot = new GameObject("S1API_TrashPrefabs") + { + hideFlags = HideFlags.HideAndDontSave, + }; + _prefabRoot.SetActive(false); + Object.DontDestroyOnLoad(_prefabRoot); + return _prefabRoot; + } } } From a9a7f46a1525225527bbf09e2d2809967ead4800 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 1 Aug 2026 03:18:50 -0700 Subject: [PATCH 011/147] docs(Products): refine authoring guidance --- .../Rendering/PresentationWorkbenchTests.cs | 9 + .../Console/PresentationWorkbenchCommand.cs | 4 +- S1API/Products/CustomProductSaveDescriptor.cs | 12 +- .../CustomProductSaveProviderRegistry.cs | 11 +- S1API/Products/PackagingDefinition.cs | 11 +- S1API/Products/ProductDefinition.cs | 44 +- S1API/Products/ProductDefinitionWrapper.cs | 13 +- S1API/Products/ProductInstance.cs | 31 +- S1API/Products/ProductMixingProfileBuilder.cs | 13 +- S1API/Products/ProductPopulator.cs | 55 ++- S1API/docs/generic-custom-products.md | 2 +- S1API/docs/presentation-workbench.md | 8 +- S1API/docs/product-kinds.md | 4 +- S1API/docs/products-api.md | 20 +- S1API/docs/products-populator.md | 92 ++-- S1API/docs/products-system.md | 462 +++--------------- S1API/docs/runtime-additives.md | 113 ++--- S1API/index.md | 4 - 18 files changed, 284 insertions(+), 624 deletions(-) diff --git a/S1API.Tests/Rendering/PresentationWorkbenchTests.cs b/S1API.Tests/Rendering/PresentationWorkbenchTests.cs index 7554883b..70a360de 100644 --- a/S1API.Tests/Rendering/PresentationWorkbenchTests.cs +++ b/S1API.Tests/Rendering/PresentationWorkbenchTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using S1API.Internal.Console; using S1API.Internal.Rendering; using S1API.Rendering; using UnityEngine; @@ -7,6 +8,14 @@ namespace S1API.Tests.Rendering; public sealed class PresentationWorkbenchTests { + [Fact] + public void ConsoleCommandUsesTheCompactCommandWord() + { + Assert.Equal( + "presentationworkbench", + new PresentationWorkbenchCommand().CommandWord); + } + [Theory] [InlineData("product")] [InlineData("PRODUCT")] diff --git a/S1API/Internal/Console/PresentationWorkbenchCommand.cs b/S1API/Internal/Console/PresentationWorkbenchCommand.cs index 92b41352..abe28115 100644 --- a/S1API/Internal/Console/PresentationWorkbenchCommand.cs +++ b/S1API/Internal/Console/PresentationWorkbenchCommand.cs @@ -14,13 +14,13 @@ public PresentationWorkbenchCommand() { } - public override string CommandWord => "presentation_workbench"; + public override string CommandWord => "presentationworkbench"; public override string CommandDescription => "Open the local icon and equippable presentation authoring workbench."; public override string ExampleUsage => - "presentation_workbench [product|item] | close"; + "presentationworkbench [product|item] | close"; public override void ExecuteCommand(List args) { diff --git a/S1API/Products/CustomProductSaveDescriptor.cs b/S1API/Products/CustomProductSaveDescriptor.cs index 5a106663..1e0ef500 100644 --- a/S1API/Products/CustomProductSaveDescriptor.cs +++ b/S1API/Products/CustomProductSaveDescriptor.cs @@ -8,8 +8,8 @@ namespace S1API.Products /// /// /// This descriptor deliberately contains no Unity objects, asset references, delegates, or - /// process-local references. A provider may use to retain its own - /// bounded scalar configuration. + /// process-local references. A provider may use for its own + /// bounded scalar configuration. It must recreate assets and callbacks from local mod resources. /// public sealed class CustomProductSaveDescriptor { @@ -65,15 +65,19 @@ public CustomProductSaveDescriptor( } /// Reconstructs a custom product from a persisted scalar descriptor. + /// + /// Register the provider before save restoration. The returned builder must retain the descriptor's + /// stable product ID. Returning preserves S1API's safe missing-content path. + /// public interface ICustomProductSaveProvider { /// Gets the stable, namespaced provider ID. string ProviderId { get; } /// Gets the highest provider descriptor version this provider accepts. int MaximumDescriptorVersion { get; } - /// Recreates and registers the descriptor's product. + /// Returns the fully configured builder that recreates the descriptor's product. /// The validated scalar descriptor. - /// A fully configured, unbuilt definition builder, or to use S1API's safe fallback. + /// A fully configured, unbuilt definition builder, or to skip restoration safely. CustomProductDefinitionBuilder? Restore(CustomProductSaveDescriptor descriptor); } } diff --git a/S1API/Products/CustomProductSaveProviderRegistry.cs b/S1API/Products/CustomProductSaveProviderRegistry.cs index ea237091..7feae8d8 100644 --- a/S1API/Products/CustomProductSaveProviderRegistry.cs +++ b/S1API/Products/CustomProductSaveProviderRegistry.cs @@ -4,9 +4,18 @@ namespace S1API.Products { /// Registers process-lifetime providers for custom-product save descriptors. + /// + /// Register providers during early mod initialization, before custom-product save restoration. + /// Provider IDs are durable and case-insensitive. A conflicting provider cannot replace the + /// provider that already owns that ID. + /// public static class CustomProductSaveProviderRegistry { - /// Registers a provider, or returns the existing equivalent provider. + /// Registers a save provider, or returns the existing equivalent provider. + /// The provider that reconstructs one family of saved custom products. + /// The registered provider, or the existing equivalent provider. + /// Thrown when is . + /// Thrown when another provider already owns the same ID. public static ICustomProductSaveProvider Register(ICustomProductSaveProvider provider) { return CustomProductSavePersistence.RegisterProvider(provider); diff --git a/S1API/Products/PackagingDefinition.cs b/S1API/Products/PackagingDefinition.cs index c89e91b3..e008f10b 100644 --- a/S1API/Products/PackagingDefinition.cs +++ b/S1API/Products/PackagingDefinition.cs @@ -13,8 +13,13 @@ namespace S1API.Products { /// - /// Represents a type of packaging in-game. + /// Represents a native packaging type in the active game runtime. /// + /// + /// Packaging definitions describe capacity and stealth. They do not create packaging assets + /// or alter a product's allowed packaging policy. Resolve a live definition through + /// or the item registry. + /// public class PackagingDefinition : ItemDefinition { /// @@ -31,13 +36,13 @@ internal PackagingDefinition(S1ItemFramework.ItemDefinition s1ItemDefinition) : base(s1ItemDefinition) { } /// - /// The quantity that this packaging can hold. + /// Gets the native product quantity this packaging can hold. /// public int Quantity => S1PackagingDefinition.Quantity; /// - /// The stealth level of this packaging. + /// Gets the runtime-agnostic stealth level for this packaging. /// public StealthLevel StealthLevel => S1PackagingDefinition.StealthLevel.ToAPI(); diff --git a/S1API/Products/ProductDefinition.cs b/S1API/Products/ProductDefinition.cs index 75f9a3cb..a62aa6de 100644 --- a/S1API/Products/ProductDefinition.cs +++ b/S1API/Products/ProductDefinition.cs @@ -20,8 +20,14 @@ namespace S1API.Products { /// - /// Represents a product definition in the game. + /// Represents one registered product type in the active game runtime. /// + /// + /// A definition supplies the shared identity, price, properties, icon, and packaging policy + /// for its instances. It is a wrapper over a native definition, not a new product-registration + /// mechanism. Use the custom-product or native-family builders when a mod needs to register + /// a definition. + /// public class ProductDefinition : Items.Storable.StorableItemDefinition { /// @@ -39,43 +45,50 @@ internal ProductDefinition(S1Product.ProductDefinition productDefinition) { } /// - /// The price associated with this product. + /// Gets the current price selected by the native product system. /// public float Price => S1ProductDefinition.Price; /// - /// The base price associated with this product. + /// Gets the native base price before market adjustments. /// public float BasePrice => S1ProductDefinition.BasePrice; /// - /// The market value associated with this product. + /// Gets the product's native market value. /// public float MarketValue => S1ProductDefinition.MarketValue; /// - /// Creates an instance of this product in-game. + /// Creates an unpackaged standard-quality instance of this definition. /// - /// The quantity of product. - /// An instance of the product. + /// The native product quantity for the new instance. + /// A new API wrapper around the native product instance. public override ItemInstance CreateInstance(int quantity = 1) => new ProductInstance(CrossType.As(S1ProductDefinition.GetDefaultInstance(quantity))); /// - /// Gets the in-game icon associated with the product. + /// Gets the current native inventory icon. /// + /// + /// The returned sprite is owned by the active Unity runtime. Do not destroy it. A + /// generated custom-product icon can replace this reference after its capture completes. + /// public new Sprite Icon { get { return S1ProductDefinition.Icon; } } /// - /// The list of product properties for this definition. - /// Returns runtime-agnostic property wrappers that work on both Mono and IL2CPP. + /// Gets runtime-agnostic wrappers for the definition's product properties. /// + /// + /// Each call creates a read-only snapshot that works on both Mono and IL2CPP. Do not use + /// the wrapper objects as stable identity keys; use their IDs when identity matters. + /// public IReadOnlyList Properties { get @@ -166,11 +179,14 @@ public System.Collections.Generic.IReadOnlyList Dru S1ProductDefinition.DrugType; /// - /// Creates a packaged instance of this product with the specified packaging. + /// Creates a standard-quality instance with the supplied native packaging. /// - /// The quantity of the product. - /// The packaging to apply to the product. - /// A packaged product instance, or null if packaging is not found. + /// The native product quantity for the new instance. + /// A live packaging definition from the active game runtime. + /// + /// A packaged product instance, or when the packaging cannot be + /// converted for the active runtime or native construction fails. + /// public ProductInstance? CreatePackagedInstance(int quantity, PackagingDefinition packaging) { try diff --git a/S1API/Products/ProductDefinitionWrapper.cs b/S1API/Products/ProductDefinitionWrapper.cs index 9f1a5955..473f040e 100644 --- a/S1API/Products/ProductDefinitionWrapper.cs +++ b/S1API/Products/ProductDefinitionWrapper.cs @@ -10,15 +10,20 @@ namespace S1API.Products { /// - /// Provides functionality to wrap and convert generic product definitions into their specific type-derived definitions. + /// Selects the most specific API wrapper for a registered native product definition. /// + /// + /// The wrapper exposes native-family types when available and returns + /// only for definitions registered through S1API's + /// custom-product metadata. It never changes the native definition or its identity. + /// public static class ProductDefinitionWrapper { /// - /// Converts a generic into its corresponding typed wrapper. + /// Returns the most specific wrapper available for a product definition. /// - /// The raw product definition to be processed and converted. - /// A wrapped instance of with type-specific methods and properties, or the input definition if no specific wrapper applies. + /// The product definition to classify. + /// A typed wrapper, or when no more specific wrapper applies. public static ProductDefinition Wrap(ProductDefinition def) { return Wrap(def.S1ProductDefinition, def); diff --git a/S1API/Products/ProductInstance.cs b/S1API/Products/ProductInstance.cs index 1e7a7a6a..f2de7cd2 100644 --- a/S1API/Products/ProductInstance.cs +++ b/S1API/Products/ProductInstance.cs @@ -12,11 +12,12 @@ namespace S1API.Products { /// - /// Represents an instance of a product in the game. + /// Represents one product stack or item in the active game runtime. /// /// - /// This class defines specific properties and behaviors for a product instance, - /// such as quality, packaging, and definition, derived from the S1API's item instance structure. + /// Product instances inherit quantity and base item behavior from . + /// This wrapper adds product definition, quality, packaging, and property access without + /// exposing runtime-specific native types. /// public class ProductInstance : S1ItemInstance { @@ -27,8 +28,7 @@ public class ProductInstance : S1ItemInstance CrossType.As(S1ItemInstance); /// - /// Represents an instance of a product, derived from a specific in-game product item instance, - /// with additional properties for packaging, quality, and product definition. + /// Creates a wrapper around a native product item instance. /// internal ProductInstance(S1Product.ProductItemInstance productInstance) : base(productInstance) @@ -36,22 +36,26 @@ internal ProductInstance(S1Product.ProductItemInstance productInstance) } /// - /// Indicates whether the product instance has applied packaging. + /// Gets whether the instance currently has native packaging. /// public bool IsPackaged => S1ProductInstance.AppliedPackaging; /// - /// Provides access to the packaging information applied to the product, - /// represented as a specific packaging definition instance. + /// Gets the packaging applied to this instance. /// + /// + /// Check before reading this property. The native runtime only + /// provides a packaging definition for packaged instances. + /// public PackagingDefinition AppliedPackaging => new PackagingDefinition(S1ProductInstance.AppliedPackaging); /// - /// Represents the quality level of the product instance. + /// Gets the runtime-agnostic quality level assigned to this instance. /// /// - /// Quality levels provide a measure of the product's grading, ranging from "Trash" to "Heavenly". + /// This value comes from the native instance. Creating an instance through + /// uses the native standard quality. /// public Quality Quality => S1ProductInstance.Quality.ToAPI(); @@ -63,12 +67,11 @@ internal ProductInstance(S1Product.ProductItemInstance productInstance) CrossType.As(S1ProductInstance.Definition)); /// - /// Gets the list of properties associated with the product definition. + /// Gets runtime-agnostic wrappers for the properties on . /// /// - /// This property provides an unmodifiable list of properties associated - /// with the underlying product definition. Each property represents - /// a specific characteristic or behavior of the corresponding product. + /// Properties belong to the definition, not the individual stack. The returned list is a + /// read-only snapshot from . /// public IReadOnlyList Properties => Definition.Properties; } diff --git a/S1API/Products/ProductMixingProfileBuilder.cs b/S1API/Products/ProductMixingProfileBuilder.cs index 504e152d..f89e1614 100644 --- a/S1API/Products/ProductMixingProfileBuilder.cs +++ b/S1API/Products/ProductMixingProfileBuilder.cs @@ -14,7 +14,9 @@ public sealed class ProductMixingProfileBuilder private string? _outputFactoryIdentity; private int _outputFactoryVersion; - /// Creates a profile builder for a registered logical product kind. + /// Creates a mixing-profile builder for a registered logical product kind. + /// The stable logical kind that opts into mixing. + /// Thrown when is . public ProductMixingProfileBuilder(ProductKind productKind) { _productKind = productKind ?? throw new ArgumentNullException(nameof(productKind)); @@ -35,7 +37,10 @@ public ProductMixingProfileBuilder WithMixerMap(ProductMixingMap mixerMap) return this; } - /// Sets the deterministic factory used to name, price, and optionally transform generated outputs. + /// Sets the deterministic factory that names, prices, and optionally transforms generated outputs. + /// A deterministic factory invoked for each native mixing output. + /// This builder. + /// Thrown when is . public ProductMixingProfileBuilder WithOutputFactory(Func outputFactory) { _outputFactory = outputFactory ?? throw new ArgumentNullException(nameof(outputFactory)); @@ -63,7 +68,9 @@ public ProductMixingProfileBuilder WithOutputFactoryCompatibility( return this; } - /// Registers this immutable profile. + /// Builds and registers this immutable mixing profile. + /// The registered profile, or the existing equivalent profile. + /// Thrown when no output factory was configured. public ProductMixingProfile Build() { if (_outputFactory == null) diff --git a/S1API/Products/ProductPopulator.cs b/S1API/Products/ProductPopulator.cs index aed302b6..c3c1703b 100644 --- a/S1API/Products/ProductPopulator.cs +++ b/S1API/Products/ProductPopulator.cs @@ -19,15 +19,20 @@ namespace S1API.Products { /// - /// Utility methods for populating storage with product instances. + /// Creates product instances and adds them to storage for mod-owned setup flows. /// + /// + /// These helpers read products discovered in the active save. They do not register, + /// discover, list, or stock products in a shop. Use explicit custom-product lifecycle APIs + /// before calling them for mod-owned definitions. + /// public static class ProductPopulator { /// - /// Gets a packaging definition by its ID. + /// Resolves a native packaging definition by its item ID. /// /// The ID of the packaging (e.g., "baggie", "jar", "brick"). - /// The packaging definition, or null if not found. + /// The live packaging definition, or when the item is absent or not packaging. public static PackagingDefinition? GetPackaging(string packagingId) { var packaging = ItemManager.GetDefinition(packagingId); @@ -44,9 +49,9 @@ public static class ProductPopulator } /// - /// Gets all available product definitions from the game registry. + /// Gets product definitions discovered in the active save. /// - /// A list of product definitions. + /// A new list of discovered product definitions. It is empty when the save has discovered none. public static List GetAllProductDefinitions() { Debug.Log("[ProductPopulator] Getting all product definitions from ProductManager.DiscoveredProducts"); @@ -67,7 +72,7 @@ public static List GetAllProductDefinitions() } /// - /// Gets weed product definitions from the game registry. + /// Gets discovered marijuana-family product definitions. /// /// A list of weed product definitions. public static List GetWeedDefinitions() @@ -79,7 +84,7 @@ public static List GetWeedDefinitions() } /// - /// Gets meth product definitions from the game registry. + /// Gets discovered methamphetamine-family product definitions. /// /// A list of meth product definitions. public static List GetMethDefinitions() @@ -91,7 +96,7 @@ public static List GetMethDefinitions() } /// - /// Gets cocaine product definitions from the game registry. + /// Gets discovered cocaine-family product definitions. /// /// A list of cocaine product definitions. public static List GetCocaineDefinitions() @@ -103,7 +108,7 @@ public static List GetCocaineDefinitions() } /// - /// Gets shroom product definitions from the game registry. + /// Gets discovered shroom-family product definitions. /// /// A list of shroom product definitions. public static List GetShroomDefinitions() @@ -115,12 +120,12 @@ public static List GetShroomDefinitions() } /// - /// Populates a storage container with packaged products. + /// Fills available storage slots with packaged discovered products. /// /// The storage instance to populate. /// The ID of the packaging to use (e.g., "baggie", "jar", "brick"). /// The quantity of each product item. - /// The number of items successfully added. + /// The number of stacks added before storage fills, a product cannot fit, or setup fails. public static int PopulateWithPackagedProducts(StorageInstance storage, string packagingId, int quantityPerItem = 1) { Debug.Log($"[ProductPopulator] PopulateWithPackagedProducts called with packaging: {packagingId}"); @@ -190,13 +195,13 @@ public static int PopulateWithPackagedProducts(StorageInstance storage, string p } /// - /// Populates a storage container with specific packaged products by ID. + /// Adds one packaged stack for each supplied product ID that resolves and fits. /// /// The storage instance to populate. - /// List of product IDs to add. + /// Product IDs to resolve through the item registry. /// The ID of the packaging to use (e.g., "baggie", "jar", "brick"). /// Quantity of each product to add (default 1). - /// The number of items successfully added. + /// The number of stacks added. Unknown IDs and stacks that do not fit are skipped. public static int PopulateWithSpecificPackagedProducts(StorageInstance storage, List productIds, string packagingId, int quantityPerProduct = 1) { if (storage == null) @@ -245,12 +250,12 @@ public static int PopulateWithSpecificPackagedProducts(StorageInstance storage, } /// - /// Creates a packaged product instance. + /// Creates a standard-quality packaged product instance. /// /// The product definition. /// The packaging definition. /// The quantity of the product. - /// The created product instance, or null if failed. + /// A new product instance, or when native packaging conversion or construction fails. public static ProductInstance? CreatePackagedProduct(ProductDefinition productDef, PackagingDefinition packaging, int quantity) { try @@ -283,11 +288,11 @@ public static int PopulateWithSpecificPackagedProducts(StorageInstance storage, } /// - /// Populates a storage container with non-packaged products. + /// Fills available storage slots with unpackaged discovered products. /// /// The storage instance to populate. /// The quantity of each product item. - /// The number of items successfully added. + /// The number of stacks added before storage fills, a product cannot fit, or setup fails. public static int PopulateWithUnpackagedProducts(StorageInstance storage, int quantityPerItem = 1) { Debug.Log("[ProductPopulator] PopulateWithUnpackagedProducts called"); @@ -350,12 +355,12 @@ public static int PopulateWithUnpackagedProducts(StorageInstance storage, int qu } /// - /// Populates a storage container with specific non-packaged products by ID. + /// Adds one unpackaged stack for each supplied product ID that resolves and fits. /// /// The storage instance to populate. - /// List of product IDs to add. + /// Product IDs to resolve through the item registry. /// Quantity of each product to add (default 1). - /// The number of items successfully added. + /// The number of stacks added. Unknown IDs and stacks that do not fit are skipped. public static int PopulateWithSpecificProducts(StorageInstance storage, List productIds, int quantityPerProduct = 1) { if (storage == null) @@ -397,8 +402,7 @@ public static int PopulateWithSpecificProducts(StorageInstance storage, List - /// Populates a storage container with packaged weed products in jars. - /// Fills all available slots with 20 units (4 jars) of each product. + /// Fills available storage slots with discovered products in native jar packaging. /// /// The storage instance to populate. /// The number of items successfully added. @@ -408,13 +412,12 @@ public static int PopulateWithWeedProducts(StorageInstance storage) } /// - /// Populates a storage container by finding it from a GameObject. - /// Fills all slots with packaged products in the specified packaging. + /// Finds a storage entity on a game object or one of its children, then fills it with packaged products. /// /// The GameObject with a StorageEntity component. /// The ID of the packaging to use (e.g., "baggie", "jar", "brick"). /// The quantity of each product item. - /// The number of items successfully added, or -1 if storage not found. + /// The number of stacks added, or -1 when no storage entity is found. public static int PopulateFromGameObject(GameObject gameObject, string packagingId, int quantityPerItem = 1) { Debug.Log($"[ProductPopulator] PopulateFromGameObject called for '{gameObject?.name}' with packaging '{packagingId}'"); diff --git a/S1API/docs/generic-custom-products.md b/S1API/docs/generic-custom-products.md index 651a252e..8c062f0c 100644 --- a/S1API/docs/generic-custom-products.md +++ b/S1API/docs/generic-custom-products.md @@ -86,7 +86,7 @@ appropriate, or explicitly select `WithNativeMixerMap(...)` for a logical kind that has no base-game enum. Neither option changes the logical kind's identity. To give the logical kind a Product Manager section, separately register -[`ProductKindMetadata`](product-kinds.md#register-presentation-and-product-manager-metadata). +[`ProductKindMetadata`](product-kinds.md#add-optional-presentation-and-product-manager-metadata). This catalog metadata does not change definition construction, discovery, or listing. diff --git a/S1API/docs/presentation-workbench.md b/S1API/docs/presentation-workbench.md index e5fcd0fd..061f7da3 100644 --- a/S1API/docs/presentation-workbench.md +++ b/S1API/docs/presentation-workbench.md @@ -37,16 +37,16 @@ Open the native developer console after the local player has spawned. The explicit forms are: ```text -presentation_workbench product example.mod:products/focus-tablet -presentation_workbench item example.mod:items/storage-pallet -presentation_workbench close +presentationworkbench product example.mod:products/focus-tablet +presentationworkbench item example.mod:items/storage-pallet +presentationworkbench close ``` For convenience, omit the target kind to resolve a value in product, then item order: ```text -presentation_workbench example.mod:products/focus-tablet +presentationworkbench example.mod:products/focus-tablet ``` A consuming mod does not need initialization code for the workbench. If the diff --git a/S1API/docs/product-kinds.md b/S1API/docs/product-kinds.md index 0bd549ad..d9ad77b4 100644 --- a/S1API/docs/product-kinds.md +++ b/S1API/docs/product-kinds.md @@ -39,7 +39,7 @@ Building the same case-insensitive ID with the same compatibility metadata retur This makes per-load setup calls safe when they repeat an equivalent registration while rejecting two mods that claim the same logical ID differently. -## Register presentation and Product Manager metadata +## Add optional presentation and Product Manager metadata Product-kind identity and UI registration are separate. Register immutable metadata only when the kind needs a user-facing name, color, search aliases, or @@ -119,7 +119,7 @@ discover or list a definition. ## Keep definition and catalog actions explicit -These operations remain separate: +Keep these operations separate: 1. `ProductKindBuilder.Build()` registers logical identity. 2. `ProductKindMetadataBuilder.Build()` registers optional presentation and diff --git a/S1API/docs/products-api.md b/S1API/docs/products-api.md index 8eb671f2..ffb2c8e2 100644 --- a/S1API/docs/products-api.md +++ b/S1API/docs/products-api.md @@ -1,8 +1,10 @@ # Products API -This page documents the API surface in `S1API/Products/` (definitions, instances, quality, and packaging). +Use this page to inspect existing product definitions, create item instances, +work with properties and packaging, or register effect callbacks. -If you want customer preference configuration, see `S1API/docs/products-system.md`. +For a task-based map of the product APIs, see [Products system](products-system.md). +Customer preferences belong in [Customer behavior](customer-behavior.md). ## Key types @@ -32,7 +34,7 @@ For creation and lifecycle guidance, see [Logical Product Kinds](product-kinds.m ## Getting product definitions -### From the current save (discovered products) +### From the current save `ProductManager.DiscoveredProducts` returns product definitions discovered on the current save. @@ -47,7 +49,7 @@ foreach (var product in ProductManager.DiscoveredProducts) } ``` -### By item ID +### From an item ID Products are also item definitions, so you can look them up by item ID. @@ -118,7 +120,7 @@ compatible, and therefore require conditional compilation in cross-runtime mods. `DrugType.MDMA` and `DrugType.Heroin` mirror values present in the native enum. Their presence does not mean that every native product system supports those types. -## Overriding product effect behavior with callbacks +## Product effect callbacks You can register callbacks for both player and NPC product effects. @@ -252,7 +254,7 @@ save/load, reconnect, or late join. If an apply callback fails, S1API immediatel callback and permits the next native apply lifecycle call to retry. `TargetId` is the native stable player code for players and the native NPC ID for NPCs; it is not a display name. -## Creating product instances +## Create product instances ### Unpackaged @@ -310,8 +312,8 @@ void Log(ProductInstance inst) } ``` -## See Also +## See also -- `S1API/docs/products-system.md` (customer preferences and properties) -- `S1API/docs/products-populator.md` (filling storages with products) +- [Products system](products-system.md) +- [ProductPopulator](products-populator.md) - (API reference) diff --git a/S1API/docs/products-populator.md b/S1API/docs/products-populator.md index 24747eab..b42142d9 100644 --- a/S1API/docs/products-populator.md +++ b/S1API/docs/products-populator.md @@ -1,91 +1,65 @@ -# ProductPopulator (Storage Helpers) +# ProductPopulator -`S1API.Products.ProductPopulator` contains convenience helpers for creating product instances (optionally packaged) and adding them to a `S1API.Storages.StorageInstance`. +`ProductPopulator` creates product instances and adds them to a +`S1API.Storages.StorageInstance`. Use it for scripted rewards, test setups, or +stock that your mod owns. It does not register products, discover them, or add +them to shops. -This is mainly useful for: - -- shop/vendor inventories -- debug/testing -- scripted rewards and stashes - -## Get packaging by ID +## Resolve packaging ```csharp using S1API.Products; -var jar = ProductPopulator.GetPackaging("jar"); -var baggie = ProductPopulator.GetPackaging("baggie"); +PackagingDefinition? jar = ProductPopulator.GetPackaging("jar"); ``` -Common IDs depend on the base game (examples mentioned in code include: `baggie`, `jar`, `brick`). +Packaging IDs come from the installed game. Check for `null` before using one. -## Enumerate discovered products +## Read discovered products -`ProductPopulator.GetAllProductDefinitions()` reads `ProductManager.DiscoveredProducts` and returns the discovered product definitions for the current save. +`GetAllProductDefinitions()` returns products discovered in the current save. +It returns an empty collection until that save has discovered products. ```csharp +using System.Collections.Generic; using S1API.Products; -var defs = ProductPopulator.GetAllProductDefinitions(); +IReadOnlyList products = + ProductPopulator.GetAllProductDefinitions(); ``` -There are also typed filters: - -- `GetWeedDefinitions()` -- `GetMethDefinitions()` -- `GetCocaineDefinitions()` -- `GetShroomDefinitions()` +Use `GetWeedDefinitions()`, `GetMethDefinitions()`, `GetCocaineDefinitions()`, +or `GetShroomDefinitions()` when the native family matters. ## Create a packaged instance ```csharp -using S1API.Products; +PackagingDefinition? packaging = ProductPopulator.GetPackaging("jar"); +IReadOnlyList products = + ProductPopulator.GetAllProductDefinitions(); -var packaging = ProductPopulator.GetPackaging("jar"); -if (packaging != null) +if (packaging != null && products.Count > 0) { - var productDef = ProductPopulator.GetAllProductDefinitions()[0]; - var inst = ProductPopulator.CreatePackagedProduct(productDef, packaging, quantity: 20); + ProductInstance? instance = ProductPopulator.CreatePackagedProduct( + products[0], packaging, quantity: 20); } ``` -## Populate a storage - -### From a StorageInstance +## Populate storage ```csharp -using S1API.Products; -using S1API.Storages; - -int added = ProductPopulator.PopulateWithPackagedProducts(storage, packagingId: "jar", quantityPerItem: 20); +int added = ProductPopulator.PopulateWithPackagedProducts( + storage, + packagingId: "jar", + quantityPerItem: 20); ``` -### From a GameObject - -If you have a `GameObject` containing a storage entity (or in children), you can populate it directly: - -```csharp -using S1API.Products; - -int added = ProductPopulator.PopulateFromGameObject(someGameObject, packagingId: "jar", quantityPerItem: 20); -``` - -### Specific product IDs - -```csharp -using S1API.Products; - -var ids = new System.Collections.Generic.List { "weed", "cocaine" }; -int added = ProductPopulator.PopulateWithSpecificPackagedProducts(storage, ids, packagingId: "baggie", quantityPerProduct: 5); -``` - -## Notes - -- `ProductManager.DiscoveredProducts` is save-dependent; if nothing is discovered yet, populators that rely on it will add nothing. -- The helper methods log a lot via `UnityEngine.Debug` (intended for debugging). +Use `PopulateFromGameObject(...)` when you have a game object that contains a +storage entity. Use `PopulateWithSpecificPackagedProducts(...)` when the mod +owns an explicit list of product IDs. -## See Also +## See also -- `S1API/docs/products-api.md` +- [Products API](products-api.md) +- [Generic custom products](generic-custom-products.md) - -- diff --git a/S1API/docs/products-system.md b/S1API/docs/products-system.md index 7d1ba2f5..b3b30ece 100644 --- a/S1API/docs/products-system.md +++ b/S1API/docs/products-system.md @@ -1,421 +1,79 @@ -# Products System +# Products system -S1API provides a comprehensive system for working with products (drugs, goods) in Schedule One, including product definitions, properties, and market dynamics. +Use the product APIs to work with existing products or register fixed, +mod-owned product definitions. S1API keeps product identity, save data, and +network behavior explicit so a visual or catalog addition does not silently +change gameplay state. -## Overview +## Choose the product path -The Products system allows you to: -- Access existing product definitions (Weed, Cocaine, Meth, etc.) -- Create marijuana-family weed variants through the native product lifecycle -- Work with product property tokens such as `Munchies`, `Energizing`, and `Cyclopean` -- Retrieve product information and pricing -- Integrate products with dealers and customers +- **Read or create instances of an existing product:** start with + [Products API](products-api.md). +- **Create a marijuana-family variant through the native game path:** use + [Native weed variants](weed-variants.md). +- **Create a fixed product outside the native drug families:** use + [Generic custom products](generic-custom-products.md). +- **Group mod-owned products under a durable logical ID:** use + [Logical product kinds](product-kinds.md). +- **Fill a storage, reward, or test inventory with product instances:** use + [ProductPopulator](products-populator.md). +- **Register runtime additives for growing:** use + [Runtime additives](runtime-additives.md). -## Product Definitions +## Product concepts -Products in Schedule One are represented by `ProductDefinition` wrappers that provide access to the game's internal product system. +`ProductDefinition` describes a product type. `ProductInstance` is one stack +or item of that product. A product definition can have properties, a price, +legal status, valid packaging, and presentation data. -### Accessing Products +Use `ProductManager.DiscoveredProducts` only for products discovered in the +current save. A custom definition is registered separately, then discovery and +listing are explicit host-side actions after loading. -Product definitions are discovered per-save. Use `ProductManager.DiscoveredProducts` to enumerate what's available: +## Registration order -```csharp -using S1API.Products; +Register custom product kinds, profiles, and definitions during +`GameLifecycle.OnPreLoad`. Register the same stable IDs on every peer before +native save restoration. Defer discovery, Product Manager listing, and shop +inventory changes until `GameLifecycle.OnLoadComplete`. -foreach (var product in ProductManager.DiscoveredProducts) -{ - MelonLoader.MelonLogger.Msg($"{product.ID}: {product.Name} (${product.MarketValue})"); -} -``` +Do not use a display name as an ID. Published product and product-kind IDs are +durable, namespaced, and case-insensitive. -If you already know an item ID, you can resolve it via `ItemManager` and cast to `ProductDefinition`. +## Presentation and packaging -To create a weed variant with native registration, saves, and multiplayer -replication, see [Native Weed Variants](weed-variants.md). Other custom product -families are not currently exposed by this builder. +Generic products borrow the game's interaction scaffolding from a native +representation template. Add a `ProductPresentationProfile` only when the +product needs mod-owned loose, held, station, functional-product, or icon +visuals. Add a packaging-content profile when a filled baggie, jar, or brick +needs mod-owned contents. -### Drug Types +The [presentation workbench](presentation-workbench.md) is a local authoring +tool for registered product and item visuals. It does not persist edits or +transfer assets between peers. -S1API exposes an API-safe `S1API.Products.DrugType` enum for use in affinities: +## Product Manager and shops -```csharp -using S1API.Products; +`ProductKind` establishes logical identity. `ProductKindMetadata` optionally +adds a display name, color, icon, aliases, and an S1API-managed Product Manager +section. It does not create a definition, discover a product, list it, or add +it to a shop. -// Mirrors the base game's drug types -public enum DrugType -{ - Marijuana, - Methamphetamine, - Cocaine, - MDMA, - Shrooms, - Heroin -} -``` +Discovery, listing, and shop stock are independent actions. This lets a mod +register a product before save loading without forcing it into a player's +catalog or a vendor inventory. -## Product Properties +## Customer preferences -Products have effect properties that affect value, customer preferences, and callbacks. S1API exposes these as `PropertyBase` tokens so mods do not need to reference runtime-specific game effect types directly. +Customers use `DrugType`, property tokens, affinities, and standards when they +choose orders. Configure that behavior through the NPC builders in +[Customer behavior](customer-behavior.md), not through product registration. -### Common Properties +## Read next -```csharp -using S1API.Properties; -using S1API.Products; - -// Properties are accessed through the Property class -Property.Munchies -Property.Energizing -Property.Cyclopean -Property.Calming -Property.Euphoric -// ... many more -``` - -### Working with Properties - -```csharp -using S1API.Properties; -using S1API.Products; - -// Get a product instance -var weedProduct = ProductDefinition.GetByType(DrugType.Marijuana); - -if (weedProduct != null) -{ - // Properties are accessed through the product's internal system - // You'll typically use properties when configuring customers - - // Example: Customer preferences for properties - .WithPreferredProperties(Property.Munchies, Property.Energizing, Property.Cyclopean) -} -``` - -## Product Instances - -`ProductInstance` represents an actual instance of a product with specific properties: - -```csharp -using S1API.Products; - -// Product instances are typically created/managed by the game -// Access them through game systems or events - -public void HandleProductSold(ProductInstance instance) -{ - if (instance != null) - { - var definition = instance.Definition; // Get the product definition - // Work with the specific product instance - } -} -``` - -## Using Products with Customers - -Products are most commonly used when configuring customer NPCs: - -```csharp -using S1API.Entities; -using S1API.Economy; -using S1API.Growing; -using S1API.Properties; - -.WithCustomerDefaults(cd => -{ - // Customer spending and order frequency - cd.WithSpending(minWeekly: 500f, maxWeekly: 2000f) - .WithOrdersPerWeek(2, 5) - - // Drug preferences and affinities - .WithAffinities(new[] - { - (DrugType.Marijuana, 0.45f), // Likes weed - (DrugType.Cocaine, -0.2f), // Dislikes cocaine - (DrugType.Methamphetamine, 0.0f) // Neutral on meth - }) - - // Preferred product properties - .WithPreferredProperties( - Property.Munchies, - Property.Energizing, - Property.Cyclopean - ) - - // Quality standards - .WithStandards(CustomerStandard.High); -}); -``` - -## Product Properties Reference - -The current `Property` helper exposes these built-in tokens: - -- `Property.Munchies` -- `Property.AntiGravity` -- `Property.Energizing` -- `Property.Focused` -- `Property.Smelly` -- `Property.Euphoric` -- `Property.Cyclopean` -- `Property.Slippery` -- `Property.Shrinking` -- `Property.Seizure` -- `Property.Electrifying` -- `Property.Zombifying` -- `Property.Disorienting` -- `Property.Sedating` -- `Property.CalorieDense` -- `Property.TropicThunder` -- `Property.Toxic` -- `Property.ThoughtProvoking` -- `Property.Lethal` -- `Property.Calming` -- `Property.Schizophrenic` -- `Property.Spicy` -- `Property.Laxative` -- `Property.BrightEyed` -- `Property.Sneaky` -- `Property.Jennerising` -- `Property.Balding` -- `Property.Glowie` -- `Property.Refreshing` -- `Property.Athletic` -- `Property.LongFaced` -- `Property.Paranoia` -- `Property.Gingeritis` -- `Property.Foggy` -- `Property.Explosive` - -## Customer Affinities - -Customer affinities determine how much a customer likes or dislikes specific drugs: - -```csharp -// Affinity values range from -1.0 to 1.0 -.WithAffinities(new[] -{ - (DrugType.Marijuana, 0.8f), // Strongly prefers - (DrugType.Cocaine, 0.3f), // Somewhat likes - (DrugType.Methamphetamine, 0.0f), // Neutral - (DrugType.Heroin, -0.5f) // Dislikes -}) -``` - -- **Positive values (0.0 to 1.0)**: Customer likes this drug type -- **Negative values (-1.0 to 0.0)**: Customer dislikes this drug type -- **Zero (0.0)**: Customer is neutral - -## Customer Standards - -Quality standards determine what quality products a customer will accept: - -```csharp -public enum CustomerStandard -{ - VeryLow, // Accepts any quality - Low, // Accepts poor to good quality - Medium, // Accepts average to good quality - High, // Only accepts good to excellent quality - VeryHigh // Only accepts excellent quality -} - -// Usage -.WithCustomerDefaults(cd => -{ - cd.WithStandards(CustomerStandard.High); // Picky customer -}); -``` - -## Complete Customer Example - -Here's a complete example of an NPC customer with detailed product preferences: - -```csharp -using S1API.Entities; -using S1API.Economy; -using S1API.GameTime; -using S1API.Growing; -using S1API.Properties; -using UnityEngine; - -public sealed class SelectiveCustomer : NPC -{ - public override bool IsPhysical => true; - - protected override void ConfigurePrefab(NPCPrefabBuilder builder) - { - builder.WithIdentity("selective_customer", "Sarah", "Johnson") - .WithSpawnPosition(new Vector3(0, 0, 0)) - .WithAppearanceDefaults(av => - { - av.Gender = 1.0f; - av.Height = 0.95f; - }) - .EnsureCustomer() - .WithCustomerDefaults(cd => - { - // High spending, selective customer - cd.WithSpending(minWeekly: 800f, maxWeekly: 3000f) - .WithOrdersPerWeek(2, 4) - .WithPreferredOrderDay(Day.Friday) - .WithOrderTime(1800) // 6 PM - - // Quality conscious - .WithStandards(CustomerStandard.High) - .AllowDirectApproach(false) // Must be introduced - .GuaranteeFirstSample(true) - - // Relationship requirements - .WithMutualRelationRequirement(minAt50: 3.0f, maxAt100: 4.5f) - .WithCallPoliceChance(0.05f) // Low risk - - // Addiction profile - .WithDependence(baseAddiction: 0.2f, dependenceMultiplier: 1.2f) - - // Drug preferences - loves weed, dislikes hard drugs - .WithAffinities(new[] - { - (DrugType.Marijuana, 0.9f), // Strongly prefers - (DrugType.Cocaine, -0.6f), // Strongly dislikes - (DrugType.Methamphetamine, -0.8f) // Very much dislikes - }) - - // Property preferences - .WithPreferredProperties( - Property.Calming, - Property.Euphoric, - Property.Munchies - ); - }) - .WithRelationshipDefaults(r => - { - r.WithDelta(2.0f) - .SetUnlocked(false) - .SetUnlockType(NPCRelationship.UnlockType.Introduction); - }); - } - - protected override void OnCreated() - { - base.OnCreated(); - Appearance.Build(); - - Dialogue.BuildAndSetDatabase(db => - { - db.WithModuleEntry("Reactions", "GREETING", - "I only deal with quality products. No junk."); - }); - - Aggressiveness = 1f; - Region = Region.Downtown; - Schedule.Enable(); - } -} -``` - -## Best Practices - -1. **Balanced Affinities**: Don't make all affinities extreme - mix preferences for realistic customers - -2. **Property Consistency**: Match preferred property tokens with drug affinities. Use actual `S1API.Properties.Property` constants, not inferred product stats. - -3. **Quality Standards**: Match standards with spending levels - - High spenders → High/VeryHigh standards - - Low spenders → Low/Medium standards - -4. **Addiction Progression**: Use `WithDependence()` to create realistic addiction dynamics - -5. **Risk Assessment**: Balance `WithCallPoliceChance()` with customer value and relationship - -## Property Discovery - -To discover all available properties, you can enumerate them at runtime: - -```csharp -using S1API.Properties; -using S1API.Properties.Interfaces; -using System.Reflection; - -// Get all static Property fields -var propertyType = typeof(Property); -var properties = propertyType.GetFields( - BindingFlags.Public | BindingFlags.Static -); - -foreach (var field in properties) -{ - if (field.FieldType == typeof(PropertyBase)) - { - var prop = (PropertyBase)field.GetValue(null); - MelonLogger.Msg($"Property: {field.Name}"); - } -} -``` - -## Common Patterns - -### Creating a Weed Enthusiast - -```csharp -.WithCustomerDefaults(cd => -{ - cd.WithAffinities(new[] { (DrugType.Marijuana, 0.9f) }) - .WithPreferredProperties( - Property.Calming, - Property.Munchies, - Property.Euphoric - ) - .WithStandards(CustomerStandard.High); -}); -``` - -### Creating a Party Customer - -```csharp -.WithCustomerDefaults(cd => -{ - cd.WithAffinities(new[] - { - (DrugType.Cocaine, 0.7f), - (DrugType.Marijuana, 0.4f) - }) - .WithPreferredProperties( - Property.Energizing, - Property.Euphoric, - Property.Focused - ) - .WithStandards(CustomerStandard.Medium); -}); -``` - -### Creating a Desperate Customer - -```csharp -.WithCustomerDefaults(cd => -{ - cd.WithSpending(50f, 200f) // Low budget - .WithAffinities(new[] - { - (DrugType.Methamphetamine, 0.8f) - }) - .WithStandards(CustomerStandard.VeryLow) // Accepts anything - .WithDependence(0.8f, 1.5f); // Highly addicted -}); -``` - -## Technical Notes - -- Product definitions are wrappers around the game's internal product system -- Properties use a token-based system internally for cross-runtime compatibility -- Customer preferences are saved with the game's save system -- Addiction levels affect order frequency and spending over time - -## See Also - -- [Customer Behavior](customer-behavior.md) - Detailed customer configuration -- [Dealer System](dealer-system.md) - Creating dealers who distribute products -- [Custom NPCs](custom-npcs.md) - Core NPC creation -- [CustomNPCTest Example](https://github.com/ifBars/S1API/tree/main/CustomNPCTest) -- - Products API Reference -- - Properties API Reference +1. Read [Products API](products-api.md) for wrappers, instances, packaging, and + effect callbacks. +2. Choose [Native weed variants](weed-variants.md) or + [Generic custom products](generic-custom-products.md) for registration. +3. Add [Logical product kinds](product-kinds.md) only when another system needs + a durable category or Product Manager metadata. diff --git a/S1API/docs/runtime-additives.md b/S1API/docs/runtime-additives.md index 34bff6ec..93ac9ca2 100644 --- a/S1API/docs/runtime-additives.md +++ b/S1API/docs/runtime-additives.md @@ -1,97 +1,66 @@ -# Runtime Additives +# Runtime additives -Create runtime additives (`AdditiveDefinition`) through the additive builder API. +Create `AdditiveDefinition` instances with the additive builder. Definitions +are read-only after registration, so configure their effects before `Build()`. -## Important Notes +Register additives before save restoration, preferably in +`GameLifecycle.OnPreLoad`. An additive that registers later may not be +available to restored items or grow containers. -- `AdditiveDefinition` is builder-only and intentionally read-only after registration to avoid mid-session mutation issues -- Configure additive effects during build time -- For best results, register additives before save data loads -- Prefer `GameLifecycle.OnPreLoad` when possible - -## Example: Recommended Timing +## Register an additive ```csharp -using MelonLoader; using S1API.Items; using S1API.Lifecycle; -public class MyMod : MelonMod +GameLifecycle.OnPreLoad += () => { - public override void OnSceneWasLoaded(int buildIndex, string sceneName) - { - if (sceneName != "Main") - return; - - GameLifecycle.OnPreLoad += RegisterItems; - } - - private static void RegisterItems() - { - var growthBooster = AdditiveItemCreator.CreateBuilder() - .WithBasicInfo( - id: "mymod_growth_booster", - name: "Growth Booster", - description: "A custom growth enhancer additive.", - category: ItemCategory.Growing - ) - .WithStackLimit(10) - .WithPricing(basePurchasePrice: 150f, resellMultiplier: 0.5f) - .WithEffects( - yieldMultiplier: 1.5f, - instantGrowth: 0.5f, - qualityChange: 1.0f - ) - .Build(); - - MelonLogger.Msg($"Registered additive: {growthBooster.Name} ({growthBooster.ID})"); - } -} + AdditiveDefinition growthBooster = AdditiveItemCreator.CreateBuilder() + .WithBasicInfo( + id: "my-mod:growth-booster", + name: "Growth Booster", + description: "A custom growing additive.", + category: ItemCategory.Growing) + .WithStackLimit(10) + .WithPricing(basePurchasePrice: 150f, resellMultiplier: 0.5f) + .WithEffects( + yieldMultiplier: 1.5f, + instantGrowth: 0.5f, + qualityChange: 1f) + .Build(); +}; ``` -## Cloning an Existing Additive +## Clone a native additive ```csharp -var variant = AdditiveItemCreator.CloneFrom("pgr") - .WithBasicInfo("mymod_pgr_variant", "PGR Variant", "A tweaked PGR.", ItemCategory.Growing) - .WithEffects(1.25f, 0.25f, 0.0f) +AdditiveDefinition variant = AdditiveItemCreator.CloneFrom("pgr") + .WithBasicInfo( + "my-mod:pgr-variant", + "PGR Variant", + "A modified growing additive.", + ItemCategory.Growing) + .WithEffects(1.25f, 0.25f, 0f) .Build(); ``` -## Allowing Additives on Grow Containers - -Grow containers have a fixed additive allowlist (`GrowContainer.AllowedAdditives`). S1API can extend that allowlist globally so mods do not need to patch `GrowContainer.InitializeGridItem`. +## Allow an additive in grow containers -Notes: - -- Applies to all grow containers -- Duplicate `AllowAdditive(...)` calls are a no-op -- If an ID cannot be resolved to an `AdditiveDefinition` at runtime, S1API warns once and skips it +Grow containers use a global allowlist. Register the additive first, then add +its stable ID during `OnPreLoad`: ```csharp -using MelonLoader; using S1API.Growing; -using S1API.Lifecycle; -public class MyMod : MelonMod -{ - public override void OnSceneWasLoaded(int buildIndex, string sceneName) - { - if (sceneName != "Main") - return; - - GameLifecycle.OnPreLoad += () => - { - GrowContainerAdditives.AllowAdditive("mymod_growth_booster"); - }; - } -} +GameLifecycle.OnPreLoad += () => + GrowContainerAdditives.AllowAdditive("my-mod:growth-booster"); ``` -## See Also +Repeated calls for the same ID do nothing. If S1API cannot resolve the ID to an +`AdditiveDefinition`, it logs one warning and skips it. + +## See also -- [Item Registration & Basics](item-registration-basics.md) -- [Builder API Reference](item-builder-reference.md) +- [Item registration](item-registration-basics.md) +- [Item builder reference](item-builder-reference.md) - -- -- diff --git a/S1API/index.md b/S1API/index.md index 13caf77e..be89b882 100644 --- a/S1API/index.md +++ b/S1API/index.md @@ -84,10 +84,6 @@ _layout: landing

Cutscenes

Play local camera-driven cinematics with cross-runtime cleanup, skip controls, fades, and title cards.

- -

Law enforcement

-

Use checkpoint, curfew, patrol, pursuit, and dispatch abstractions for police-oriented mods.

-
From d8ef64ba954a253a180415dc8129639ba744a921 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 1 Aug 2026 03:33:42 -0700 Subject: [PATCH 012/147] fix(Trash): surface prefab registration collisions --- .../Trash/TrashApiCompatibilityTests.cs | 17 +++++++++- .../Storable/StorableItemDefinitionBuilder.cs | 31 ++++++++++++++++++- S1API/Products/ProductPopulator.cs | 9 +++--- S1API/Trash/TrashManager.cs | 12 ++++++- S1API/docs/products-populator.md | 3 ++ 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/S1API.Tests/Trash/TrashApiCompatibilityTests.cs b/S1API.Tests/Trash/TrashApiCompatibilityTests.cs index 558f23bd..25be3717 100644 --- a/S1API.Tests/Trash/TrashApiCompatibilityTests.cs +++ b/S1API.Tests/Trash/TrashApiCompatibilityTests.cs @@ -17,7 +17,7 @@ public void TrashManagerExposesPrefabRegistration() } [Fact] - public void StorableBuilderExposesDefaultAndExplicitTrashIds() + public void StorableBuilderExposesBothTrashPrefabOverloads() { Assert.NotNull( typeof(StorableItemDefinitionBuilder).GetMethod( @@ -28,4 +28,19 @@ public void StorableBuilderExposesDefaultAndExplicitTrashIds() nameof(StorableItemDefinitionBuilder.WithTrashPrefab), new[] { typeof(string), typeof(GameObject), typeof(bool) })); } + + [Fact] + public void StorableBuilderDerivesDefaultTrashIdFromItemId() + { + Assert.Equal( + "example.mod:precursor_trash", + StorableItemDefinitionBuilderBase + .ResolveTrashId("example.mod:precursor", trashId: null)); + Assert.Equal( + "example.mod:empty-bottle", + StorableItemDefinitionBuilderBase + .ResolveTrashId( + "example.mod:precursor", + "example.mod:empty-bottle")); + } } diff --git a/S1API/Items/Storable/StorableItemDefinitionBuilder.cs b/S1API/Items/Storable/StorableItemDefinitionBuilder.cs index e27d1b52..3f430442 100644 --- a/S1API/Items/Storable/StorableItemDefinitionBuilder.cs +++ b/S1API/Items/Storable/StorableItemDefinitionBuilder.cs @@ -356,6 +356,13 @@ public TSelf WithoutStationItem() /// /// Prefab containing a native TrashItem component. /// Whether an existing trash registration may be replaced. + /// The builder instance for fluent chaining. + /// + /// Thrown when is . + /// + /// + /// Thrown when has no native TrashItem component. + /// public TSelf WithTrashPrefab( GameObject trashPrefab, bool replaceExisting = false) @@ -372,6 +379,14 @@ public TSelf WithTrashPrefab( /// Stable trash ID used for spawning and persistence. /// Prefab containing a native TrashItem component. /// Whether an existing trash registration may be replaced. + /// The builder instance for fluent chaining. + /// + /// Thrown when is . + /// + /// + /// Thrown when has no native TrashItem component, or + /// is empty or whitespace. + /// public TSelf WithTrashPrefab( string? trashId, GameObject trashPrefab, @@ -466,7 +481,16 @@ private void ApplyTrashPrefab() "A station item is required before configuring its trash prefab."); } - string trashId = _trashId ?? $"{Definition.ID}_trash"; + string trashId = ResolveTrashId(Definition.ID, _trashId); + GameObject? existing = + global::S1API.Trash.TrashManager.GetTrashPrefab(trashId); + if (!_replaceExistingTrash && existing != null) + { + Logger.Warning( + $"Item '{Definition.ID}' requested trash ID '{trashId}', " + + $"but '{existing.name}' is already registered and will be reused."); + } + GameObject registered = global::S1API.Trash.TrashManager.RegisterTrashPrefab( trashId, _trashPrefab, @@ -475,6 +499,11 @@ private void ApplyTrashPrefab() registered.GetComponent(); } + internal static string ResolveTrashId(string itemId, string? trashId) + { + return trashId ?? $"{itemId}_trash"; + } + /// /// INTERNAL: Builds and returns the raw game item definition without registering. /// Used internally by S1API. Modders should use instead. diff --git a/S1API/Products/ProductPopulator.cs b/S1API/Products/ProductPopulator.cs index c3c1703b..a4dd1598 100644 --- a/S1API/Products/ProductPopulator.cs +++ b/S1API/Products/ProductPopulator.cs @@ -22,9 +22,10 @@ namespace S1API.Products /// Creates product instances and adds them to storage for mod-owned setup flows. /// /// - /// These helpers read products discovered in the active save. They do not register, - /// discover, list, or stock products in a shop. Use explicit custom-product lifecycle APIs - /// before calling them for mod-owned definitions. + /// Discovery-based helpers read products discovered in the active save. ID-based helpers + /// resolve registered definitions through the item registry, while direct-creation helpers + /// use the supplied definition. These APIs do not register products or stock shops. Use + /// explicit custom-product lifecycle APIs before calling them for mod-owned definitions. /// public static class ProductPopulator { @@ -405,7 +406,7 @@ public static int PopulateWithSpecificProducts(StorageInstance storage, List /// The storage instance to populate. - /// The number of items successfully added. + /// The number of stacks successfully added. public static int PopulateWithWeedProducts(StorageInstance storage) { return PopulateWithPackagedProducts(storage, "jar", 20); diff --git a/S1API/Trash/TrashManager.cs b/S1API/Trash/TrashManager.cs index 8d73dbde..19b3a161 100644 --- a/S1API/Trash/TrashManager.cs +++ b/S1API/Trash/TrashManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using S1API.Lifecycle; +using S1API.Logging; using UnityEngine; using Object = UnityEngine.Object; @@ -18,6 +19,7 @@ namespace S1API.Trash ///
public static class TrashManager { + private static readonly Log Logger = new Log("TrashManager"); private static readonly object RegistrationGate = new object(); private static readonly Dictionary RegisteredPrefabs = @@ -68,7 +70,8 @@ public static class TrashManager lock (RegistrationGate) { - return RegisteredPrefabs.TryGetValue(id, out var registered) + return RegisteredPrefabs.TryGetValue(id, out var registered) && + registered != null ? registered.gameObject : null; } @@ -111,7 +114,14 @@ public static GameObject RegisterTrashPrefab( existing != null) { if (!replaceExisting) + { + Logger.Warning( + $"Trash ID '{id}' is already registered. " + + $"Keeping '{existing.gameObject.name}' and ignoring " + + $"'{trashPrefab.name}'. Set replaceExisting to true " + + "to replace the existing prefab."); return existing.gameObject; + } Object.Destroy(existing.gameObject); } diff --git a/S1API/docs/products-populator.md b/S1API/docs/products-populator.md index b42142d9..e8445cd6 100644 --- a/S1API/docs/products-populator.md +++ b/S1API/docs/products-populator.md @@ -47,6 +47,9 @@ if (packaging != null && products.Count > 0) ## Populate storage +In this example, `storage` already refers to the target +`S1API.Storages.StorageInstance`. + ```csharp int added = ProductPopulator.PopulateWithPackagedProducts( storage, From b5aa533396b84c7b8e236b00c8cbabb21b7fd1a4 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 1 Aug 2026 03:52:35 -0700 Subject: [PATCH 013/147] chore(Release): bump version to 3.1.1 --- S1API/S1API.cs | 2 +- S1API/S1API.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 4ceaec1e..159126a2 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -11,7 +11,7 @@ using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.0", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.1", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 29f4766d..bfd6536c 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.1.0 + 3.1.1 From 9c6e0adcdf1a8f94e9ce650116ea7b7d37dc68c7 Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 1 Aug 2026 13:17:44 -0700 Subject: [PATCH 014/147] fix(phoneapp): preserve native home screen icons Instantiate and register a dedicated native app icon before applying custom phone app metadata so Delivery and other built-in icons remain intact. --- S1API/PhoneApp/PhoneApp.cs | 71 ++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index f892692f..54f68102 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -1,12 +1,14 @@ +using System; +using System.Collections.Generic; using System.IO; -using UnityEngine; -using UnityEngine.UI; -using Object = UnityEngine.Object; +using HarmonyLib; +using MelonLoader; using S1API.Internal.Abstraction; using S1API.Internal.Patches; using S1API.Internal.Utils; -using System; -using MelonLoader; +using UnityEngine; +using UnityEngine.UI; +using Object = UnityEngine.Object; #if IL2CPPMELON using Il2CppScheduleOne.UI; using Il2CppScheduleOne.UI.Phone; @@ -325,16 +327,14 @@ internal void SpawnIcon(HomeScreen homeScreenInstance) return; } - // Find the LAST icon (the one most recently added) - Transform? lastIcon = appIcons.transform.childCount > 0 ? appIcons.transform.GetChild(appIcons.transform.childCount - 1) : null; - if (lastIcon == null) + GameObject? iconObj = CreateAppIcon(homeScreenInstance, appIcons.transform); + if (iconObj == null) { - Logger.Error("No icons found in AppIcons."); + Logger.Error($"Failed to create an icon for {AppName}."); return; } - GameObject iconObj = lastIcon.gameObject; - iconObj.name = AppName; // Rename it now + iconObj.name = AppName; // Cache icon image for future updates Transform imageTransform = iconObj.transform.Find("Mask/Image"); @@ -375,6 +375,55 @@ internal void SpawnIcon(HomeScreen homeScreenInstance) } } + /// + /// Creates and registers an independent home-screen icon using the native icon prefab. + /// + private static GameObject? CreateAppIcon(HomeScreen homeScreenInstance, Transform parent) + { +#if IL2CPPMELON + GameObject? iconPrefab = homeScreenInstance.appIconPrefab; +#else + GameObject? iconPrefab = AccessTools.Field( + typeof(HomeScreen), + "appIconPrefab")?.GetValue(homeScreenInstance) as GameObject; +#endif + if (iconPrefab == null) + { + Logger.Error("HomeScreen appIconPrefab was unavailable."); + return null; + } + + GameObject iconObject = Object.Instantiate(iconPrefab, parent); + Button? button = iconObject.GetComponent