Skip to content

growing: add GrowContainer allowed additives registry + injection patch#41

Merged
ifBars merged 1 commit intoifBars:stablefrom
Khundiann:growing/add-GrowContainer-allowed-additives-registry-+-injection-patch
Jan 28, 2026
Merged

growing: add GrowContainer allowed additives registry + injection patch#41
ifBars merged 1 commit intoifBars:stablefrom
Khundiann:growing/add-GrowContainer-allowed-additives-registry-+-injection-patch

Conversation

@Khundiann
Copy link

@Khundiann Khundiann commented Jan 28, 2026

Summary

This PR adds S1API support for allowing additional additives on grow containers at runtime, without each mod needing to patch GrowContainer.InitializeGridItem.

It introduces a small global registry of additive item IDs and a centralized Harmony patch that injects the corresponding AdditiveDefinition entries into GrowContainer.AllowedAdditives during container initialization.

What’s included

  • New API:
    • S1API.Growing.GrowContainerAdditives
      • AllowAdditive(string additiveItemId) (idempotent)
      • GetAllowedAdditiveIds()
  • Internal injection (Harmony patch):
    • Patch target: ScheduleOne.Growing.GrowContainer.InitializeGridItem(ItemInstance, Grid, Vector2, int, string)
    • Injects resolved AdditiveDefinition entries into GrowContainer.AllowedAdditives
    • IL2CPP-safe array assignment (Il2CppReferenceArray<AdditiveDefinition>)
  • Docs:
    • Added “Allowing additives on Grow Containers” section to S1API/docs/items.md

Usage (recommended)

Register allowed additives during GameLifecycle.OnPreLoad:

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 += () =>
        {
            // Allow a runtime additive ID you registered earlier in OnPreLoad
            GrowContainerAdditives.AllowAdditive("mymod_growth_booster");
        };
    }
}

Implementation

  • Scope: global only (applies to all GrowContainer instances).
  • Duplicate registrations: silent no-op.
  • Missing/invalid IDs: warn once + skip.
  • Model: allow-only (no remove/disallow API).

Testing

Find the smoke-test console command source file in the first comment under this PR!

  • DEV-local smoke test command:
    • s1api_dev_growcontainer_additives_smoke_test runtime
    • s1api_dev_growcontainer_additives_smoke_test basegame <additiveId> (example: pgr)
    • s1api_dev_growcontainer_additives_smoke_test arm runtime
    • s1api_dev_growcontainer_additives_smoke_test arm basegame <additiveId>
  • Validates:
    • Runtime mode: creates a runtime additive, registers it, allows it, injects it into GrowContainer.AllowedAdditives, and validates idempotency.
    • Base-game mode: resolves an existing additive ID from Registry, allows it, injects it into GrowContainer.AllowedAdditives, and validates idempotency.
    • Armed mode: waits for Main + a GrowContainer (skips if none appear) then runs the same validations once.
  • Verified on Mono and IL2CPP (manual + arm; runtime + base-game modes).

Notes / rationale

  • Centralizing the injection avoids multiple mods patching the same method (InitializeGridItem) and fighting over ordering.
  • The base game can remove runtime-added items from Registry on scene changes; therefore missing additive IDs are handled defensively (warn+skip) rather than breaking container initialization.

@Khundiann
Copy link
Author

Khundiann commented Jan 28, 2026

RuntimeGrowContainerAdditivesSmokeTestCommand.cs

using System;
using System.Collections;
using System.Collections.Generic;
using MelonLoader;
using S1API.Console;
using S1API.Growing;
using S1API.Internal.Utils;
using S1API.Internal.Patches;
using S1API.Items;
using S1API.Logging;
using UnityEngine;
using UnityEngine.SceneManagement;

#if (IL2CPPMELON)
using S1Growing = Il2CppScheduleOne.Growing;
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
using S1Registry = Il2CppScheduleOne.Registry;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1Growing = ScheduleOne.Growing;
using S1ItemFramework = ScheduleOne.ItemFramework;
using S1Registry = ScheduleOne.Registry;
#endif

namespace S1API.Internal.DevLocal
{
    /// <summary>
    /// DEV-LOCAL: Console command to smoke-test GrowContainer allowed additive injection.
    /// This file is intended to stay out of PR diffs.
    /// </summary>
    internal sealed class RuntimeGrowContainerAdditivesSmokeTestCommand : BaseConsoleCommand
    {
        private static readonly Log Logger = new Log("S1API.DevGrowContainerAdditivesSmokeTest");
        private static bool _armed;
        private static bool _running;

        public override string CommandWord => "s1api_dev_growcontainer_additives_smoke_test";
        public override string CommandDescription => "Runs a smoke test for GrowContainerAdditives. Requires an explicit mode: 'runtime' or 'basegame <additiveId>'. Use 'arm' to run once after the Main scene loads (waits for a GrowContainer).";
        public override string ExampleUsage => "s1api_dev_growcontainer_additives_smoke_test [arm] (runtime | basegame <additiveId>)";

        public override void ExecuteCommand(List<string> args)
        {
            var parsed = ParseArgs(args);
            if (parsed == null)
            {
                Logger.Warning("[GrowContainerAdditivesSmokeTest] Missing required args.");
                Logger.Warning("[GrowContainerAdditivesSmokeTest] Examples:");
                Logger.Warning("[GrowContainerAdditivesSmokeTest] - s1api_dev_growcontainer_additives_smoke_test runtime");
                Logger.Warning("[GrowContainerAdditivesSmokeTest] - s1api_dev_growcontainer_additives_smoke_test basegame pgr");
                Logger.Warning("[GrowContainerAdditivesSmokeTest] - s1api_dev_growcontainer_additives_smoke_test arm runtime");
                Logger.Warning("[GrowContainerAdditivesSmokeTest] - s1api_dev_growcontainer_additives_smoke_test arm basegame pgr");
                return;
            }

            if (parsed.Value.Arm)
            {
                Arm(parsed.Value.Mode, parsed.Value.AdditiveId);
                return;
            }

            RunSmokeTest("Manual", parsed.Value.Mode, parsed.Value.AdditiveId);
        }

        private static void Arm(Mode mode, string additiveId)
        {
            if (_armed)
            {
                Logger.Msg("[GrowContainerAdditivesSmokeTest] Already armed. Trigger a scene load to run once after Main is loaded.");
                return;
            }

            _armed = true;
            if (_running)
                return;

            _running = true;
            MelonCoroutines.Start(WaitForMainAndGrowContainerThenRun(mode, additiveId));
            Logger.Msg("[GrowContainerAdditivesSmokeTest] Armed. Smoke test will run once after Main is active and a GrowContainer exists.");
        }

        private static IEnumerator WaitForMainAndGrowContainerThenRun(Mode mode, string additiveId)
        {
            try
            {
                // One-shot: reset arm state as soon as the coroutine starts.
                _armed = false;

                var start = Time.realtimeSinceStartup;
                const float timeoutSeconds = 30f;

                // Wait until Main is the active scene (some mods/loader setups can call this command earlier).
                while (Time.realtimeSinceStartup - start < timeoutSeconds)
                {
                    try
                    {
                        if (string.Equals(SceneManager.GetActiveScene().name, "Main", StringComparison.OrdinalIgnoreCase))
                            break;
                    }
                    catch
                    {
                        // ignore
                    }

                    yield return null;
                }

                // Give the scene a moment to instantiate objects.
                yield return null;
                yield return null;

                S1Growing.GrowContainer container = null;
                while (Time.realtimeSinceStartup - start < timeoutSeconds)
                {
                    container = UnityEngine.Object.FindObjectOfType<S1Growing.GrowContainer>();
                    if (container != null)
                        break;

                    yield return null;
                }

                if (container == null)
                {
                    Logger.Warning("[GrowContainerAdditivesSmokeTest] SKIP (Armed): No GrowContainer found in Main within timeout.");
                    yield break;
                }

                RunSmokeTest("Armed", mode, additiveId, containerOverride: container);
            }
            finally
            {
                _running = false;
            }
        }

        private static void RunSmokeTest(string tag, Mode mode, string additiveId, S1Growing.GrowContainer containerOverride = null)
        {
            Logger.Msg($"[GrowContainerAdditivesSmokeTest] START ({tag})");

            try
            {
                var container = containerOverride ?? UnityEngine.Object.FindObjectOfType<S1Growing.GrowContainer>();
                if (container == null)
                {
                    Logger.Warning("[GrowContainerAdditivesSmokeTest] SKIP: No GrowContainer found in scene to validate AllowedAdditives.");
                    return;
                }

                var resolvedId = ResolveAdditiveIdOrThrow(mode, additiveId);

                GrowContainerAdditives.AllowAdditive(resolvedId);

                // Call the internal injector method from the patch to validate the core logic without relying on timing.
                GrowContainerPatches.ApplyRegisteredAllowedAdditives(container);

                if (!ContainsAllowed(container, resolvedId))
                    throw new Exception($"GrowContainer.AllowedAdditives did not contain '{resolvedId}' after injection.");

                var countBefore = GetAllowedCount(container);
                GrowContainerPatches.ApplyRegisteredAllowedAdditives(container);
                var countAfter = GetAllowedCount(container);
                if (countAfter != countBefore)
                    throw new Exception($"Duplicate injection detected. Count changed from {countBefore} to {countAfter}.");

                Logger.Msg($"[GrowContainerAdditivesSmokeTest] OK: AllowedAdditives contains '{resolvedId}' (count={countAfter}).");
                Logger.Msg($"[GrowContainerAdditivesSmokeTest] PASS ({tag})");
            }
            catch (Exception ex)
            {
                Logger.Error($"[GrowContainerAdditivesSmokeTest] FAIL ({tag}): {ex.Message}\n{ex.StackTrace}");
            }
        }

        private static string ResolveAdditiveIdOrThrow(Mode mode, string additiveId)
        {
            switch (mode)
            {
                case Mode.Runtime:
                {
                    var suffix = DateTime.UtcNow.Ticks.ToString("x");
                    var id = $"s1api_dev_test_allowed_additive_{suffix}";

                    AdditiveItemCreator.CreateBuilder()
                        .WithBasicInfo(
                            id: id,
                            name: $"S1API Dev Allowed Additive {suffix}",
                            description: "DEV-LOCAL additive for GrowContainerAdditives smoke test.",
                            category: ItemCategory.Growing
                        )
                        .WithEffects(1.0f, 0.0f, 0.0f)
                        .Build();

                    return id;
                }
                case Mode.BaseGame:
                {
                    var id = (additiveId ?? string.Empty).Trim();
                    if (string.IsNullOrWhiteSpace(id))
                        throw new ArgumentException("Base-game additive ID is required for 'basegame' mode.", nameof(additiveId));

                    S1ItemFramework.ItemDefinition def;
                    try { def = S1Registry.GetItem(id); }
                    catch (Exception ex)
                    {
                        throw new InvalidOperationException(
                            "ScheduleOne.Registry is not available yet. Run this command after Main is loaded (or use 'arm').",
                            ex);
                    }

                    if (def == null)
                        throw new InvalidOperationException($"Base-game additive '{id}' was not found in the registry.");

                    if (!CrossType.Is(def, out S1ItemFramework.AdditiveDefinition _))
                        throw new InvalidOperationException($"Item '{id}' is not an AdditiveDefinition.");

                    return id;
                }
                default:
                    throw new InvalidOperationException($"Unknown mode '{mode}'.");
            }
        }

        private static ParsedArgs? ParseArgs(List<string> args)
        {
            if (args == null || args.Count == 0)
                return null;

            var a0 = (args[0] ?? string.Empty).Trim();
            if (string.IsNullOrWhiteSpace(a0))
                return null;

            var arm = false;
            var idx = 0;
            if (string.Equals(a0, "arm", StringComparison.OrdinalIgnoreCase))
            {
                arm = true;
                idx = 1;
            }

            if (idx >= args.Count)
                return null;

            var modeRaw = (args[idx] ?? string.Empty).Trim();
            if (string.IsNullOrWhiteSpace(modeRaw))
                return null;

            if (string.Equals(modeRaw, "runtime", StringComparison.OrdinalIgnoreCase))
            {
                return new ParsedArgs(arm, Mode.Runtime, string.Empty);
            }

            if (string.Equals(modeRaw, "basegame", StringComparison.OrdinalIgnoreCase))
            {
                if (idx + 1 >= args.Count)
                    return null;

                var id = (args[idx + 1] ?? string.Empty).Trim();
                if (string.IsNullOrWhiteSpace(id))
                    return null;

                return new ParsedArgs(arm, Mode.BaseGame, id);
            }

            return null;
        }

        private static bool ContainsAllowed(S1Growing.GrowContainer container, string additiveId)
        {
            try
            {
                var arr = container.AllowedAdditives;
                if (arr == null)
                    return false;

                var len = GetAllowedCount(container);
                for (int i = 0; i < len; i++)
                {
                    var def = arr[i];
                    if (def != null && string.Equals(def.ID, additiveId, StringComparison.OrdinalIgnoreCase))
                        return true;
                }
            }
            catch
            {
                // ignore
            }

            return false;
        }

        private static int GetAllowedCount(S1Growing.GrowContainer container)
        {
            try
            {
                var arr = container.AllowedAdditives;
                return arr?.Length ?? 0;
            }
            catch
            {
                return 0;
            }
        }

        private enum Mode
        {
            Runtime,
            BaseGame
        }

        private readonly struct ParsedArgs
        {
            public ParsedArgs(bool arm, Mode mode, string additiveId)
            {
                Arm = arm;
                Mode = mode;
                AdditiveId = additiveId;
            }

            public bool Arm { get; }
            public Mode Mode { get; }
            public string AdditiveId { get; }
        }
    }
}

@ifBars ifBars self-assigned this Jan 28, 2026
@ifBars ifBars added the enhancement New feature or request label Jan 28, 2026
@ifBars ifBars merged commit d753176 into ifBars:stable Jan 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants