-
-
Notifications
You must be signed in to change notification settings - Fork 17
stations: add Chemistry Station recipe registration #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stable
Are you sure you want to change the base?
stations: add Chemistry Station recipe registration #40
Conversation
|
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;
}
}
} |
| _recipeEntriesFieldLookupAttempted = true; | ||
|
|
||
| // Mono: field is named "recipeEntries" | ||
| // IL2CPP: field name can differ; find by type instead of name. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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..
ifBars
left a comment
There was a problem hiding this 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
…stration-(builder-+-late-UI-sync)
|
Implemented the changes and everything still works as expected. |
Summary
This PR adds S1API support for registering Chemistry Station recipes (
StationRecipe) at runtime, so mods don’t need brittle reflection/Harmony patches onChemistryStationCanvas.Design goals:
StationRecipedirectly.Awakeshould appear the next time the Chemistry Station UI is opened.What’s included
S1API.Stations.ChemistryStationRecipe(read-only wrapper)S1API.Stations.ChemistryStationRecipeBuilder(fluent builder; validates required state; defaultsUnlocked=true,IsDiscovered=true)S1API.Stations.ChemistryStationRecipes(registry;Build()auto-registers;RecipeIDconflicts warn+skip, first wins)ChemistryStationCanvas.Awakeprefix: inject pre-registered recipes intoRecipesbefore UI entries are builtChemistryStationCanvas.Openprefix: sync late registrations (ensureRecipes+ ensureStationRecipeEntryexists for each recipe)recipeEntriesviaInternal.Utils.ReflectionUtils(field-or-property + property scan)S1API/docs/stations.md(new Stations page; Chemistry recipes documented here)S1API/docs/toc.yml+S1API/docs/api-overview.mdS1API/docs/items.mdnow links to Stations instead of embedding station contentUsage (recommended)
Register recipes during
GameLifecycle.OnPreLoad:Implementation
RecipeIDconflicts: warn + skip (first wins).Build()auto-registers (andBuildInternal()exists for raw/native use).IsDiscovered = true,Unlocked = true.StationItem.Testing
Find the smoke-test console command source file in the first comment under this PR!
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 nextGameLifecycle.OnPreLoad)RecipeIDChemistryStationCanvas.Recipesand aStationRecipeEntryexists (manual mode)Notes / rationale
ChemistryStationCanvas.Awake()from theRecipeslist; late additions won’t be visible without syncing the privaterecipeEntrieslist.FieldInfo, so the patch usesInternal.Utils.ReflectionUtilsand property scanning to accessrecipeEntriessafely across runtimes.