Skip to content

Conversation

@Khundiann
Copy link

Summary

This PR adds S1API support for registering Chemistry Station recipes (StationRecipe) at runtime, so mods don’t need brittle reflection/Harmony patches on ChemistryStationCanvas.

Design goals:

  • Builder-first: mods build recipes via an S1API builder instead of touching native StationRecipe directly.
  • Late registration supported: recipes registered after Awake should appear the next time the Chemistry Station UI is opened.

What’s included

  • New recipe APIs:
    • S1API.Stations.ChemistryStationRecipe (read-only wrapper)
    • S1API.Stations.ChemistryStationRecipeBuilder (fluent builder; validates required state; defaults Unlocked=true, IsDiscovered=true)
    • S1API.Stations.ChemistryStationRecipes (registry; Build() auto-registers; RecipeID conflicts warn+skip, first wins)
  • Internal UI injection/sync (Harmony patches):
    • ChemistryStationCanvas.Awake prefix: inject pre-registered recipes into Recipes before UI entries are built
    • ChemistryStationCanvas.Open prefix: sync late registrations (ensure Recipes + ensure StationRecipeEntry exists for each recipe)
    • IL2CPP-safe access to recipeEntries via Internal.Utils.ReflectionUtils (field-or-property + property scan)
  • Docs:
    • Added S1API/docs/stations.md (new Stations page; Chemistry recipes documented here)
    • Updated S1API/docs/toc.yml + S1API/docs/api-overview.md
    • S1API/docs/items.md now links to Stations instead of embedding station content

Usage (recommended)

Register recipes during GameLifecycle.OnPreLoad:

ChemistryStationRecipes.CreateAndRegister(b => b
    .WithTitle("My Custom Recipe")
    .WithCookTimeMinutes(10)
    .WithFinalLiquidColor(new Color(0.2f, 0.8f, 0.4f, 1f))
    .WithProduct(itemId: "mymod_custom_product_item", quantity: 5)
    .WithIngredient(itemId: "ingredient_item_id", quantity: 1)
    .WithIngredientOptions(new[] { "ingredient_variant_a", "ingredient_variant_b" }, quantity: 1)
);

Implementation

  • RecipeID conflicts: warn + skip (first wins).
  • Registration model: Build() auto-registers (and BuildInternal() exists for raw/native use).
  • Visibility defaults: IsDiscovered = true, Unlocked = true.
  • Late registration: supported — should appear on next Chemistry Station UI open.
  • Ingredient validity: throw during build/registration if an ingredient item is missing or lacks a valid StationItem.

Testing

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

  • Added DEV-local smoke test command:
    • s1api_dev_chemistry_recipe_smoke_test (runs immediately; validates UI presence when a Chemistry Station exists in-scene)
    • s1api_dev_chemistry_recipe_smoke_test arm (runs once on next GameLifecycle.OnPreLoad)
  • Validates:
    • Expected throws (missing product; missing ingredients)
    • Successful registration with a unique RecipeID
    • UI sync: recipe appears in ChemistryStationCanvas.Recipes and a StationRecipeEntry exists (manual mode)
  • Verified on Mono and IL2CPP (manual + arm).

Notes / rationale

  • The base game builds UI entries in ChemistryStationCanvas.Awake() from the Recipes list; late additions won’t be visible without syncing the private recipeEntries list.
  • IL2CPP does not reliably expose private fields as FieldInfo, so the patch uses Internal.Utils.ReflectionUtils and property scanning to access recipeEntries safely across runtimes.

@Khundiann
Copy link
Author

RuntimeChemistryRecipeSmokeTestCommand.cs

using System;
using System.Collections.Generic;
using System.Reflection;
using S1API.Console;
using S1API.Internal.Utils;
using S1API.Lifecycle;
using S1API.Logging;
using S1API.Stations;
using UnityEngine;
using CrossTypeUtils = global::S1API.Internal.Utils.CrossType;

#if (IL2CPPMELON)
using S1ItemFramework = Il2CppScheduleOne.ItemFramework;
using S1ObjectScripts = Il2CppScheduleOne.ObjectScripts;
using S1UIStations = Il2CppScheduleOne.UI.Stations;
#elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX)
using S1ItemFramework = ScheduleOne.ItemFramework;
using S1ObjectScripts = ScheduleOne.ObjectScripts;
using S1UIStations = ScheduleOne.UI.Stations;
#endif

namespace S1API.Internal.DevLocal
{
    /// <summary>
    /// DEV-LOCAL: Console command to smoke-test Chemistry Station recipe registration.
    /// This file is intended to stay out of PR diffs.
    /// </summary>
    internal sealed class RuntimeChemistryRecipeSmokeTestCommand : BaseConsoleCommand
    {
        private static readonly Log Logger = new Log("S1API.DevChemistryRecipeSmokeTest");
        private static bool _armed;
        private static bool _loggedCanvasFieldDump;

        public override string CommandWord => "s1api_dev_chemistry_recipe_smoke_test";
        public override string CommandDescription => "Runs a smoke test for Chemistry Station recipe registration. Use 'arm' to run on next GameLifecycle.OnPreLoad.";
        public override string ExampleUsage => "s1api_dev_chemistry_recipe_smoke_test [arm]";

        public override void ExecuteCommand(List<string> args)
        {
            var mode = args != null && args.Count > 0 ? (args[0] ?? string.Empty).Trim().ToLowerInvariant() : string.Empty;
            if (mode == "arm")
            {
                Arm();
                return;
            }

            RunSmokeTest("Manual", tryUiChecks: true);
        }

        private static void Arm()
        {
            if (_armed)
            {
                Logger.Msg("[ChemRecipeSmokeTest] Already armed. Trigger a load to run on GameLifecycle.OnPreLoad.");
                return;
            }

            _armed = true;
            GameLifecycle.OnPreLoad += OnPreLoad;
            Logger.Msg("[ChemRecipeSmokeTest] Armed. Smoke test will run on next GameLifecycle.OnPreLoad.");
        }

        private static void OnPreLoad()
        {
            try
            {
                RunSmokeTest("OnPreLoad", tryUiChecks: false);
            }
            finally
            {
                _armed = false;
                GameLifecycle.OnPreLoad -= OnPreLoad;
            }
        }

        private static void RunSmokeTest(string tag, bool tryUiChecks)
        {
            Logger.Msg($"[ChemRecipeSmokeTest] START ({tag})");

            try
            {
                NegativeChecks();
                var created = PositiveRegister();

                Logger.Msg($"[ChemRecipeSmokeTest] Registered: {created.Title} ({created.RecipeID})");
                Logger.Msg($"[ChemRecipeSmokeTest] Product: {created.Product.Quantity}x{created.Product.ItemId}");
                Logger.Msg($"[ChemRecipeSmokeTest] Ingredients: {created.Ingredients.Count}");

                if (tryUiChecks)
                {
                    TryUiChecks(created);
                }

                Logger.Msg($"[ChemRecipeSmokeTest] PASS ({tag})");
            }
            catch (Exception ex)
            {
                Logger.Error($"[ChemRecipeSmokeTest] FAIL ({tag}): {ex.Message}\n{ex.StackTrace}");
            }
        }

        private static void NegativeChecks()
        {
            try
            {
                new ChemistryStationRecipeBuilder()
                    .WithTitle("Should fail")
                    .WithCookTimeMinutes(5)
                    .Build();
                throw new Exception("Expected Build() to throw when WithProduct was not called, but it did not.");
            }
            catch
            {
                Logger.Msg("[ChemRecipeSmokeTest] OK: Build() without WithProduct threw.");
            }

            try
            {
                new ChemistryStationRecipeBuilder()
                    .WithProduct(TryFindAnyStorableItemId() ?? "cash", 1)
                    .Build();
                throw new Exception("Expected Build() to throw when no ingredients were added, but it did not.");
            }
            catch
            {
                Logger.Msg("[ChemRecipeSmokeTest] OK: Build() without ingredients threw.");
            }
        }

        private static ChemistryStationRecipe PositiveRegister()
        {
            var productId = TryFindAnyStorableItemId();
            if (string.IsNullOrWhiteSpace(productId))
                throw new Exception("Could not find a storable item ID for test product.");

            // Use a large/random-ish quantity to avoid colliding with base-game recipe IDs.
            var qty = (int)(DateTime.UtcNow.Ticks % 5000) + 1000; // [1000..5999]

            var ingredientIds = TryFindStationIngredientIds(3);
            if (ingredientIds.Count == 0)
                throw new Exception("Could not find any ingredient items with StationItem for smoke test.");

            var builder = new ChemistryStationRecipeBuilder()
                .WithTitle($"S1API Dev Chemistry Recipe {qty}x{productId}")
                .WithCookTimeMinutes(5)
                .WithFinalLiquidColor(new Color(0.7f, 0.2f, 0.9f, 1f))
                .WithProduct(productId, qty);

            for (int i = 0; i < ingredientIds.Count; i++)
            {
                builder.WithIngredient(ingredientIds[i], 1);
            }

            return builder.Build();
        }

        private static void TryUiChecks(ChemistryStationRecipe recipe)
        {
            var canvas = UnityEngine.Object.FindObjectOfType<S1UIStations.ChemistryStationCanvas>();
            if (canvas == null)
            {
                Logger.Warning("[ChemRecipeSmokeTest] SKIP(UI): ChemistryStationCanvas not found.");
                return;
            }

            var station = UnityEngine.Object.FindObjectOfType<S1ObjectScripts.ChemistryStation>();
            if (station == null)
            {
                Logger.Warning("[ChemRecipeSmokeTest] SKIP(UI): ChemistryStation not found in scene.");
                return;
            }

            try
            {
                // Trigger Open prefix patch (late registration sync).
                canvas.Open(station);
            }
            catch (Exception ex)
            {
                Logger.Warning($"[ChemRecipeSmokeTest] SKIP(UI): Calling ChemistryStationCanvas.Open failed: {ex.Message}");
                return;
            }

            try
            {
                bool inRecipes = false;
                for (int i = 0; i < canvas.Recipes.Count; i++)
                {
                    var r = canvas.Recipes[i];
                    if (r != null && string.Equals(r.RecipeID, recipe.RecipeID, StringComparison.OrdinalIgnoreCase))
                    {
                        inRecipes = true;
                        break;
                    }
                }

                Logger.Msg(inRecipes
                    ? "[ChemRecipeSmokeTest] OK(UI): Canvas.Recipes contains recipe."
                    : "[ChemRecipeSmokeTest] WARN(UI): Canvas.Recipes does not contain recipe (injection may have been skipped).");
            }
            catch (Exception ex)
            {
                Logger.Warning($"[ChemRecipeSmokeTest] WARN(UI): Failed to inspect Canvas.Recipes: {ex.Message}");
            }

            try
            {
                var entriesObj = FindRecipeEntriesList(canvas);
#if (IL2CPPMELON || IL2CPPBEPINEX)
                var entries = entriesObj as Il2CppSystem.Collections.Generic.List<S1UIStations.StationRecipeEntry>;
#else
                var entries = entriesObj as List<S1UIStations.StationRecipeEntry>;
#endif
                if (entries == null)
                {
                    Logger.Warning("[ChemRecipeSmokeTest] SKIP(UI): recipeEntries field not found.");
                    DumpChemistryCanvasFieldsOnce();
                    return;
                }

                bool hasEntry = false;
                for (int i = 0; i < entries.Count; i++)
                {
                    var e = entries[i];
                    if (e?.Recipe == null)
                        continue;

                    if (string.Equals(e.Recipe.RecipeID, recipe.RecipeID, StringComparison.OrdinalIgnoreCase))
                    {
                        hasEntry = true;
                        break;
                    }
                }

                Logger.Msg(hasEntry
                    ? "[ChemRecipeSmokeTest] OK(UI): recipeEntries contains entry."
                    : "[ChemRecipeSmokeTest] WARN(UI): recipeEntries missing entry (late sync may have failed).");
            }
            catch (Exception ex)
            {
                Logger.Warning($"[ChemRecipeSmokeTest] WARN(UI): Failed to inspect recipeEntries: {ex.Message}");
            }
            finally
            {
                try { canvas.Close(removeUI: false); } catch { /* ignore */ }
            }
        }

        private static object? FindRecipeEntriesList(S1UIStations.ChemistryStationCanvas canvas)
        {
            try
            {
                // Mono: private field exists with this name.
                var field = typeof(S1UIStations.ChemistryStationCanvas).GetField(
                    "recipeEntries",
                    BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
                if (field != null)
                {
                    return field.GetValue(canvas);
                }
            }
            catch
            {
                // ignore and fall back to scan
            }

            try
            {
                // IL2CPP: fields may be exposed as properties on the generated proxy type.
                var byMember = ReflectionUtils.TryGetFieldOrProperty(canvas, "recipeEntries");
                if (byMember != null)
                    return byMember;
            }
            catch
            {
                // ignore
            }

            try
            {
                var t = canvas.GetType();
                const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
                foreach (var prop in t.GetProperties(flags))
                {
                    if (prop == null || !prop.CanRead)
                        continue;

                    var pt = prop.PropertyType;
                    if (pt == null || !pt.IsGenericType)
                        continue;

                    var genDef = pt.GetGenericTypeDefinition();
                    if (genDef == null)
                        continue;

                    if (!string.Equals(genDef.FullName, "System.Collections.Generic.List`1", StringComparison.Ordinal)
                        && !string.Equals(genDef.FullName, "Il2CppSystem.Collections.Generic.List`1", StringComparison.Ordinal))
                    {
                        continue;
                    }

                    var args = pt.GetGenericArguments();
                    if (args == null || args.Length != 1)
                        continue;

                    if (args[0] == typeof(S1UIStations.StationRecipeEntry))
                    {
                        return prop.GetValue(canvas);
                    }
                }
            }
            catch
            {
                // ignore
            }

            return null;
        }

        private static void DumpChemistryCanvasFieldsOnce()
        {
            if (_loggedCanvasFieldDump)
                return;

            _loggedCanvasFieldDump = true;
            try
            {
                var t = typeof(S1UIStations.ChemistryStationCanvas);
                const BindingFlags flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;

                foreach (var f in t.GetFields(flags))
                {
                    if (f == null)
                        continue;

                    Logger.Warning($"[ChemRecipeSmokeTest] Canvas field: {f.Name} : {f.FieldType?.FullName}");
                }

                // Properties can be more useful on IL2CPP.
                foreach (var p in t.GetProperties(flags))
                {
                    if (p == null)
                        continue;

                    Logger.Warning($"[ChemRecipeSmokeTest] Canvas property: {p.Name} : {p.PropertyType?.FullName}");
                }
            }
            catch (Exception ex)
            {
                Logger.Warning($"[ChemRecipeSmokeTest] Failed to dump ChemistryStationCanvas fields: {ex.Message}");
            }
        }

        private static string? TryFindAnyStorableItemId()
        {
            try
            {
                // Registry.GetAllItems isn't accessible via static; use Instance if possible
#if (IL2CPPMELON)
                var instance = Il2CppScheduleOne.Registry.Instance;
#else
                var instance = ScheduleOne.Registry.Instance;
#endif
                var items = instance?.GetAllItems();
                if (items == null)
                    return null;

                foreach (var item in items)
                {
                    if (item == null)
                        continue;

                    if (!CrossTypeUtils.Is(item, out S1ItemFramework.StorableItemDefinition _))
                        continue;

                    var id = item.ID;
                    if (!string.IsNullOrWhiteSpace(id))
                        return id;
                }
            }
            catch
            {
                // best-effort only
            }

            return null;
        }

        private static List<string> TryFindStationIngredientIds(int count)
        {
            var results = new List<string>();
            if (count <= 0)
                return results;

            try
            {
#if (IL2CPPMELON)
                var instance = Il2CppScheduleOne.Registry.Instance;
#else
                var instance = ScheduleOne.Registry.Instance;
#endif
                var items = instance?.GetAllItems();
                if (items == null)
                    return results;

                foreach (var item in items)
                {
                    if (item == null)
                        continue;

                    if (!CrossTypeUtils.Is(item, out S1ItemFramework.StorableItemDefinition storable))
                        continue;

                    if (storable.StationItem == null)
                        continue;

                    var id = item.ID;
                    if (string.IsNullOrWhiteSpace(id))
                        continue;

                    results.Add(id);
                    if (results.Count >= count)
                        break;
                }
            }
            catch
            {
                // best-effort only
            }

            return results;
        }
    }
}

@ifBars ifBars added the enhancement New feature or request label Jan 28, 2026
_recipeEntriesFieldLookupAttempted = true;

// Mono: field is named "recipeEntries"
// IL2CPP: field name can differ; find by type instead of name.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Il2Cpp fields are typically going to be a property of the same name, I believe this is due to Il2CppAssemblyGenerator, as the produced Il2Cpp prefixed assemblies seem to have all the usual fields as properties, and native il2cpp interop fields that we typically do not touch in modding. UnityExplorer can be quite handy in scenarios like this, checking an instance of a ChemistryStationCanvas on both Il2Cpp and Mono might give some insight.

In S1API we use ReflectionUtils to account for this, with helpers like TrySetFieldOrProperty and TryGetFieldOrProperty. I think using ReflectionUtils seems like a cleaner solution here, as I am unsure if the current solution would even work on Il2Cpp, and even if so, it seems redundant to iterate over all declared fields.

If you are sure this works fine on il2cpp, I don't want to bother you to refactor if you don't feel like it, if I really want to I can always do it myself later.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment!

I will refactor recipeEntries access to use Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(canvas, "recipeEntries") so it matches S1API conventions.

return false;

// Mono: direct private field access is reliable.
#if (MONOMELON || MONOBEPINEX)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I should have read the entire class before commenting LOL

I see you already use ReflectionUtils for Il2Cpp, so you could probably drop the GetRecipeEntriesField method entirely, alongside dropping the conditional compilation on lines 114-203 by also using ReflectionUtils. This should provide much cleaner code in the patches, and should still work fine.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment!

I will remove GetRecipeEntriesField and the extra reflection scanning/conditional code, and now use TryGetFieldOrProperty directly for resolving recipeEntries.

_loggedRecipeEntriesFieldDump = true;
Logger.Warning("[S1API] ChemistryStationCanvas recipeEntries field could not be resolved. Late recipe UI sync will be skipped.");

// Best-effort field dump to aid IL2CPP troubleshooting.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here, prefer to use ReflectionUtils over manually getting fields & properties for cleaner code. Feel free to add more to ReflectionUtils if you see applicable (e.g. a GetFieldsAndProperties method if you see fit, but ideally wouldn't want to iterate over all for best performance)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment!

I will remove the declared field/property iteration + dump logs and keep a single “warn once + skip late UI sync” message if recipeEntries can’t be resolved..

Copy link
Owner

@ifBars ifBars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the comments I made on ChemistryStationPatches about using ReflectionUtils

@Khundiann
Copy link
Author

Implemented the changes and everything still works as expected.
If there is anything else I have missed, let me know please.. or feel free to change it yourself.

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