diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml new file mode 100644 index 0000000..1fb4069 --- /dev/null +++ b/.github/workflows/build_test.yml @@ -0,0 +1,32 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal +# - uses: actions/upload-artifact@v7.0.0 +# with: +# name: HAModHelper.dll +# path: diff --git a/.gitignore b/.gitignore index 8d4a6c0..9a11a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin -obj \ No newline at end of file +obj +out \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Base/AssemblyOverride.cs b/HAModHelper.GamePlugin/Base/AssemblyOverride.cs new file mode 100644 index 0000000..b9bc8f9 --- /dev/null +++ b/HAModHelper.GamePlugin/Base/AssemblyOverride.cs @@ -0,0 +1,67 @@ + +using System.Reflection; +using System.Runtime.Loader; + +namespace HAModHelper.GamePlugin.Core.Debug; + +internal static class AssemblyManager +{ + public static Assembly AssemblyResolveEventListener(object sender, ResolveEventArgs args) + { + if (args is null) + { + return null!; + } + + string name = "HAModHelper.GamePlugin.Resources." + args.Name[..args.Name.IndexOf(',')] + ".dll"; + using Stream? str = Assembly.GetExecutingAssembly().GetManifestResourceStream(name); + if (str is not null) + { + var context = new AssemblyLoadContext(name, false); + return context.LoadFromStream(str); + } + return null!; + } + + public static void SetOurResolveHandlerAtFront() + { + BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic; + FieldInfo? field = null; + + Type domainType = typeof(AssemblyLoadContext); + + while (field is null) + { + if (domainType is not null) + { + field = domainType.GetField("AssemblyResolve", flags); + } + else + { + //MelonDebug.Error("domainType got set to null for the AssemblyResolve event was null"); + return; + } + if (field is null) + { + domainType = domainType.BaseType!; + } + } + + var resolveDelegate = (MulticastDelegate)field.GetValue(null)!; + Delegate[] subscribers = resolveDelegate.GetInvocationList(); + + Delegate currentDelegate = resolveDelegate; + for (int i = 0; i < subscribers.Length; i++) + { + currentDelegate = System.Delegate.RemoveAll(currentDelegate, subscribers[i])!; + } + + var newSubscriptions = new Delegate[subscribers.Length + 1]; + newSubscriptions[0] = (ResolveEventHandler)AssemblyResolveEventListener!; + System.Array.Copy(subscribers, 0, newSubscriptions, 1, subscribers.Length); + + currentDelegate = Delegate.Combine(newSubscriptions)!; + + field.SetValue(null, currentDelegate); + } +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Events.cs b/HAModHelper.GamePlugin/Base/Base.Events.cs similarity index 95% rename from HAModHelper.GamePlugin/Events.cs rename to HAModHelper.GamePlugin/Base/Base.Events.cs index 213c8b4..082fc9d 100644 --- a/HAModHelper.GamePlugin/Events.cs +++ b/HAModHelper.GamePlugin/Base/Base.Events.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; - -namespace HAModHelper.Events; +namespace HAModHelper.GamePlugin.Base.Events; public sealed class EventBus { diff --git a/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs b/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs new file mode 100644 index 0000000..fe4c371 --- /dev/null +++ b/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs @@ -0,0 +1,293 @@ +using System.Diagnostics; +using HAModHelper.GamePlugin.Assets.Systems; +using HAModHelper.GamePlugin.Items.Systems; +using HAModHelper.GamePlugin.Perks.Systems; +using HAModHelper.GamePlugin.World.Systems; +using HAModHelper.GamePlugin.Core.Debug; +using System.Net; +using BepInEx.Unity.IL2CPP; +using BepInEx; +using HarmonyLib; +using BepInEx.Logging; +using System.Reflection; +using System.Security.Cryptography; +using HAModHelper.GamePlugin.Debug; +using static FriendServerInterface; + +namespace HAModHelper.GamePlugin.Core; + +[BepInAutoPlugin] +public partial class HAMHMod : BasePlugin +{ + internal static BepInPlugin? PluginData; + internal Harmony? Harmony { get; } = new(Id); + internal static ManualLogSource Logger { private set; get; } = null!; + internal static readonly string AssemblyHash = ComputeHash(); + + static string ComputeHash() + { + var path = Assembly.GetExecutingAssembly().Location; + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(File.ReadAllBytes(path)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + static HAMHMod() + { + AssemblyManager.SetOurResolveHandlerAtFront(); + } + + public override void Load() + { + Logger = base.Log; + PluginData = MetadataHelper.GetMetadata(this); + + if (Harmony is null) + { + Log.LogError("[HAMH] WHERE THE FUCK IS HARMONY?!?!?!? (Harmony didn't load. The mod will crash once it gets to hooking required functions.)"); + } + + Log.LogInfo($"[HAMH] Starting initialization with mod version {Version}, hash {AssemblyHash}"); + + Log.LogInfo("[HAMH] Checking mods..."); + + List blacklistedModHashes = new() + { + + }; + + List blacklistedModNames = new() + { + + }; + + Log.LogInfo("[HAMH] Initializing subsystems..."); + try + { + // Subsystem init + var stopwatch = Stopwatch.StartNew(); + ItemManager.Instance.Initialize(); + stopwatch.Stop(); + Log.LogInfo($"[HAMH] Initialized ItemManager in {stopwatch.ElapsedMilliseconds}ms."); + + var stopwatch2 = Stopwatch.StartNew(); + PerkManager.Instance.Initialize(); + stopwatch2.Stop(); + Log.LogInfo($"[HAMH] Initialized PerkManager in {stopwatch2.ElapsedMilliseconds}ms."); + + var stopwatch3 = Stopwatch.StartNew(); + WorldPrefabManager.Instance.Initialize(); + stopwatch3.Stop(); + Log.LogInfo($"[HAMH] Initialized WorldPrefabManager in {stopwatch3.ElapsedMilliseconds}ms."); + + var stopwatch4 = Stopwatch.StartNew(); + CraftingInjectionManager.Instance.Initialize(); + stopwatch4.Stop(); + Log.LogInfo($"[HAMH] Initialized CraftingInjectionManager in {stopwatch4.ElapsedMilliseconds}ms."); + + var stopwatch6 = Stopwatch.StartNew(); + WorldGenManager.Instance.Initialize(); + stopwatch6.Stop(); + Log.LogInfo($"[HAMH] Initialized WorldGenManager in {stopwatch6.ElapsedMilliseconds}ms."); + + //var stopwatch3 = Stopwatch.StartNew(); + //UniverseLibConfig uvlconfig = new UniverseLibConfig + //{ + // Disable_EventSystem_Override = true + //}; + //Universe.Init(1f, null, (string msg, UnityEngine.LogType _) => { Log.LogInfo(msg);}, uvlconfig); + //stopwatch3.Stop(); + //Log.LogInfo($"[HAMH] Initialized UniverseLib in {stopwatch3.ElapsedMilliseconds}ms."); + } + catch (Exception ex) + { + Log.LogError("[HAMH] Something went terribly wrong during subsystems initialization, please contact the developer! Below is the thrown exception:"); + + Log.LogError(ex); + } + + Log.LogInfo("[HAMH] Applying Harmony patches..."); + try + { + Harmony!.PatchAll(); + } + catch (Exception ex) + { + Log.LogError("[HAMH] Failed to apply Harmony patches, please contact the developer! Below is the thrown exception:"); + + Log.LogError(ex); + } + var patches = Harmony!.GetPatchedMethods(); + if (patches.Count() == 1) + { + Log.LogError("[HAMH] Failed to apply Harmony patches, please contact the developer with your log."); + } + Log.LogInfo($"[HAMH] Applied {patches.Count()} Harmony patches."); + + // Debug init +#if DEBUG + Log.LogInfo("[HAMH-DBG] Running DebugHelper (use Release plugin to disable this!)"); + DebugHelper.Initialize(); +#endif + + Log.LogInfo("[HAMH] Initialization complete."); + } + + private static void DebugLog(string toLog) + { +#if DEBUG + Logger.LogDebug(toLog); +#endif + } + + // Restore the left/right miniwindow tabs when the friends list (screen 4) is opened. + // The current game calls HideMiniwindowHeaders for screen 4, hiding the tab that lets + // you swap to the public server browser. This postfix re-shows them after the setup runs. + [HarmonyPatch(typeof(FriendServerInterface), "ChangeFriendScreen", new Type[] { typeof(friend_window_screen) })] + private static class RestoreServerListTabPatch + { + static void Postfix(friend_window_screen new_screen) + { + if (new_screen != friend_window_screen.friend_list) return; + var wc = WindowControl.Instance; + if (wc == null) return; + wc.ShowMiniwindowHeaders(); + var wpc = WindowPrefabsControl.Instance; + if (wpc == null) return; + + var windowPrefabScreen = wpc.prefab_screens_instantiated["FRIENDS-friends_list"]; + var header = windowPrefabScreen.transform.Find("header"); + if (header != null) + header.gameObject.SetActive(false); + } + } + + [HarmonyPatch(typeof(AdvertControl), nameof(AdvertControl.LaunchAds))] + public static class IHateAds + { + public static bool Prefix() + { + return false; + } + } + + [HarmonyPatch(typeof(PopupControl), "ShowRewardAskPopup", new Type[] { typeof(AdvertControl.reward_ad_type) })] + + private static class IReallyHateAds + { + static bool Prefix(AdvertControl.reward_ad_type reward_ad_type_t) + { + DebugLog("Blocked an ad"); + return false; + } + } + + [HarmonyPatch(typeof(ResourceControl), "TryLoadInventoryItem", new Type[] { typeof(string) })] + private static class TryLoadInventoryItemPatch + { + [HarmonyPrefix] + static bool Prefix(string item_name, ref bool __result) + { + DebugLog($"[HAMH] TryLoadInventoryItem called for item: {item_name}"); + + var mgr = ItemManager.Instance; + + mgr.ProcessQueuedItems(); + + var isBaseItem = mgr.IsBaseItem(item_name); + + if (isBaseItem) + { + if (mgr.IsBaseItemBlocked(item_name)) + { + DebugLog($"[HAMH] Blocking base game item {item_name}"); + __result = false; + return false; + } + else + { + DebugLog($"[HAMH] Ignoring base game item {item_name}"); + return true; // Let the game handle the rest from here... + } + } + + var item = mgr.GetItem(item_name); + if (item != null) + { + DebugLog($"[HAMH] Providing modded item for {item_name}"); + mgr.TryInjectIntoGameCache(item_name, item); + __result = true; + return false; + } + + DebugLog($"[HAMH] No item found for {item_name}"); + return true; // Let the game handle the rest from here... + } + } + + [HarmonyPatch(typeof(inventory_ctr), "GetFullItemName", new Type[] { typeof(InventoryItem) })] + private static class GetFullItemNamePatch + { + [HarmonyPrefix] + static bool Prefix(InventoryItem item, ref string __result) + { + DebugLog("[HAMH] GetFullItemName called for item: " + item.item_name); + + var mgr = ItemManager.Instance; + + var modItem = mgr.GetItem(item.item_name); + + if (modItem != null) + { + DebugLog($"[HAMH] Returning modded name for {item.item_name}: {modItem.Name}"); + __result = modItem.Name; + return false; + } + + DebugLog($"[HAMH] No modded item found for {item.item_name}"); + + return true; // Let the game handle the rest from here... + } + } + + [HarmonyPatch(typeof(Connection), "TryConnect")] + public static class ConnectionPatch + { + [HarmonyPrefix] + public static void Prefix(Connection __instance) + { + try + { + // string targetHost = "192.168.1.196"; + string targetHost = "45.8.201.48"; + int targetPort = 7002; + + // We use GetHostAddresses for both. + // If targetHost is already an IP ("127.0.0.1"), it returns it immediately. + // If it's a domain ("...localto.net"), it resolves it first. + IPAddress[] addresses = Dns.GetHostAddresses(targetHost); + + if (addresses.Length > 0) + { + string resolvedIp = addresses[0].ToString(); + + // Hijack the Connection instance fields + if (__instance.port == 7002 || __instance.port == 7003) + { + if (__instance.ip == "104.45.198.157") + { + __instance.ip = resolvedIp; + __instance.port = targetPort; + } + + } + DebugLog($"[HAML] Redirected the Friend Server to {targetHost} ({resolvedIp}):{targetPort}"); + } + } + catch (Exception e) + { + Logger.LogError($"[SHJ-ERR] Connection hijack failed: {e.Message}"); + } + } + } +} + diff --git a/HAModHelper.GamePlugin/Base/Debug.cs b/HAModHelper.GamePlugin/Base/Debug.cs new file mode 100644 index 0000000..e9d8fcf --- /dev/null +++ b/HAModHelper.GamePlugin/Base/Debug.cs @@ -0,0 +1,30 @@ +using HAModHelper.GamePlugin.Items.Systems; +using HAModHelper.GamePlugin.Perks.Systems; + +namespace HAModHelper.GamePlugin.Debug; + +public static class DebugHelper +{ + public static void Initialize() + { + ItemManager.Instance.AddItem(new Item + { + ModId = "hamltest", + ItemId = "minosprime", + Name = "Minos Prime", + Description = "debug test item", + SpritePath = "item egg", + StackLimit = 10, + }); + + PerkManager.Instance.AddPerk(new Perk + { + ModId = "hamltest", + PerkId = "sisyphyusprime", + Name = "Sisyphus Prime", + Description = "Die.", + DetailedDescription = "Kills everything around you (when I code it)", + SpritePath = "item egg", + }); + } +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Base/Helpers.cs b/HAModHelper.GamePlugin/Base/Helpers.cs new file mode 100644 index 0000000..7dd72b9 --- /dev/null +++ b/HAModHelper.GamePlugin/Base/Helpers.cs @@ -0,0 +1,20 @@ +namespace HAModHelper.GamePlugin.Helpers; + +public static class DictHelper +{ + public static Dictionary NormalizeIL2CPPDictionary(Il2CppSystem.Collections.Generic.Dictionary dict) where T1 : notnull + { + var d = new Dictionary(); + foreach (var kvp in dict) + d[kvp.Key] = kvp.Value; + return d; + } + + public static Il2CppSystem.Collections.Generic.Dictionary DenormalizeIL2CPPDictionary(Dictionary dict) where T1 : notnull + { + var d = new Il2CppSystem.Collections.Generic.Dictionary(); + foreach (var kvp in dict) + d[kvp.Key] = kvp.Value; + return d; + } +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Entities.cs b/HAModHelper.GamePlugin/Crafting/Crafting.Events.cs similarity index 100% rename from HAModHelper.GamePlugin/Entities.cs rename to HAModHelper.GamePlugin/Crafting/Crafting.Events.cs diff --git a/HAModHelper.GamePlugin/Perks.cs b/HAModHelper.GamePlugin/Crafting/Crafting.Interfaces.cs similarity index 100% rename from HAModHelper.GamePlugin/Perks.cs rename to HAModHelper.GamePlugin/Crafting/Crafting.Interfaces.cs diff --git a/HAModHelper.GamePlugin/Crafting/Crafting.Systems.cs b/HAModHelper.GamePlugin/Crafting/Crafting.Systems.cs new file mode 100644 index 0000000..e69de29 diff --git a/HAModHelper.GamePlugin/Entities/Entities.Events.cs b/HAModHelper.GamePlugin/Entities/Entities.Events.cs new file mode 100644 index 0000000..e69de29 diff --git a/HAModHelper.GamePlugin/Entities/Entities.Interfaces.cs b/HAModHelper.GamePlugin/Entities/Entities.Interfaces.cs new file mode 100644 index 0000000..e69de29 diff --git a/HAModHelper.GamePlugin/Entities/Entities.Systems.cs b/HAModHelper.GamePlugin/Entities/Entities.Systems.cs new file mode 100644 index 0000000..e69de29 diff --git a/HAModHelper.GamePlugin/GamePlugin.cs b/HAModHelper.GamePlugin/GamePlugin.cs deleted file mode 100644 index 9055ccd..0000000 --- a/HAModHelper.GamePlugin/GamePlugin.cs +++ /dev/null @@ -1,78 +0,0 @@ -using MelonLoader; -using Il2Cpp; -using HAModHelper.GamePlugin.Items; -using System.Diagnostics; -using HarmonyLib; -using UnityEngine; - -namespace HAModHelper.GamePlugin.Core; - -internal class HAMHMod : MelonPlugin -{ - public override void OnInitializeMelon() - { - MelonLogger.Msg("[HAMH] Initializing subsystems..."); - var stopwatch = Stopwatch.StartNew(); - ItemManager.Instance.Initialize(); - stopwatch.Stop(); - MelonLogger.Msg($"[HAMH] Initialized ItemManager in {stopwatch.ElapsedMilliseconds}ms."); - - MelonLogger.Msg("[HAMH] Initialization complete."); - - MelonEvents.OnGUI.Subscribe(DrawMenu, 100); // The higher the value, the lower the priority. - } - - private void DrawMenu() - { - GUI.Box(new Rect(0, 0, 300, 500), "Test Menu"); - // button that gives you item "hamltest:minosprime" - if (GUI.Button(new Rect(10, 30, 280, 30), "Give Minos Prime")) - { - var ictr = UnityEngine.Object.FindObjectOfType(); - if (ictr != null) - { - ictr.GiveItem("hamltest:minosprime", 1, null); - } - } - } - - [HarmonyPatch(typeof(ResourceControl), "TryLoadInventoryItem", new Type[] { typeof(string) })] - static class TryLoadInventoryItemPatch -{ - static bool Prefix(object __instance, string item_name, ref bool __result) - { - MelonLogger.Msg("[HAMH] TryLoadInventoryItem called for item: " + item_name); - - var mgr = ItemManager.Instance; - - var item = mgr.GetItem(item_name); - if (item != null) - { - MelonLogger.Msg($"[HAMH] Providing modded item for {item_name}"); - Inject(item_name, item); - __result = true; - return false; - } - - if (mgr.IsBaseItemBlocked(item_name)) - { - MelonLogger.Msg($"[HAMH] Blocking base game item {item_name}"); - __result = false; - return false; - } - - MelonLogger.Msg($"[HAMH] No modded item found for {item_name}"); - return true; // Let the game handle the rest from here... - } - - static void Inject(string id, Item item) - { - var rc = UnityEngine.Object.FindObjectOfType(); - - if (rc.loaded_inventory_item_files == null) - return; - - rc.loaded_inventory_item_files[id] = ItemManager.Instance.ConvertItem(item); - } -} -} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/HAModHelper.GamePlugin.csproj b/HAModHelper.GamePlugin/HAModHelper.GamePlugin.csproj index ef8b3b0..e574d8b 100644 --- a/HAModHelper.GamePlugin/HAModHelper.GamePlugin.csproj +++ b/HAModHelper.GamePlugin/HAModHelper.GamePlugin.csproj @@ -1,45 +1,22 @@  - net8.0 + net10.0 enable enable + true + + 0.0.1 + The mod manager and game management library for Hybrid Animals. - - $(MSBuildProjectDirectory)/../LemonLibs/net8/MelonLoader.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/net8/0Harmony.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/Il2CppAssemblies/UnityEngine.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/Il2CppAssemblies/UnityEngine.CoreModule.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/Il2CppAssemblies/UnityEngine.IMGUIModule.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/net8/Il2CppInterop.Common.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/net8/Il2CppInterop.Runtime.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/Il2CppAssemblies/Assembly-CSharp.dll - True - - - $(MSBuildProjectDirectory)/../LemonLibs/Il2CppAssemblies/Il2Cppmscorlib.dll - True - - + + + + + + + \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Items.cs b/HAModHelper.GamePlugin/Items.cs deleted file mode 100644 index 5131c82..0000000 --- a/HAModHelper.GamePlugin/Items.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.Reflection; -using HarmonyLib; -using Il2Cpp; -using Il2CppSystem.Security.Cryptography; - -namespace HAModHelper.GamePlugin.Items; - -// Every item loaded in HA has a relevant class instantiated. There will be a HashSet available for access by id. -// Modifying an item's Item class will modify it at runtime in-game. -// Instantiating a new Item is totally legal and will cause it to become available ingame. -public class Item -{ - public required string ModId { get; set; } // melon mod id or "base" - public required string ItemId { get; set; } - public string Id => $"{ModId}:{ItemId}"; - - public string? Description { get; set; } - public int StackLimit { get; set; } = 1; - public ItemActions Actions { get; set; } = 0; - public string? SpritePath { get; set; } - - // Escape hatch for anything not modeled yet (including keys with spaces) - public Dictionary ExtraFields { get; } = new(); -} - -[Flags] -public enum ItemActions -{ - IsTool = 1 << 0, - IsUsable = 1 << 1, - IsConsumable = 1 << 2, - IsPlaceable = 1 << 3, -} - -internal static class ItemConverter -{ - public const string KeySpritePath = "Inventory_sprite_path"; - - public static Il2CppSystem.Collections.Generic.Dictionary ToGameFields(Item item) - { - var d = new Il2CppSystem.Collections.Generic.Dictionary(); - - // Sprite path (real key) - if (!string.IsNullOrWhiteSpace(item.SpritePath)) - d[KeySpritePath] = item.SpritePath!; - - // Extra fields override everything else (modder wins). - foreach (var (k, v) in item.ExtraFields) - { - if (string.IsNullOrWhiteSpace(k)) continue; - d[k] = v ?? ""; - } - - return d; - } - - // Optional helper: turn a game dict back into an Item (useful for GetItem(base:...)) (actually only for that) - public static Item FromGameFields(string fullId, Il2CppSystem.Collections.Generic.Dictionary fields) - { - var (modId, id) = SplitFullId(fullId); - - var item = new Item - { - ModId = modId, - ItemId = id, - }; - - if (fields.TryGetValue(KeySpritePath, out var sprite)) - item.SpritePath = sprite; - - // Everything else goes into ExtraFields (including keys with spaces) - foreach (var kvp in fields) - { - // Skip ones we modeled above - if (kvp.Key == KeySpritePath) continue; - - item.ExtraFields[kvp.Key] = kvp.Value; - } - - return item; - } - - private static (string modId, string id) SplitFullId(string fullId) - { - var idx = fullId.IndexOf(':'); - if (idx <= 0) return ("base", fullId); - return (fullId.Substring(0, idx), fullId.Substring(idx + 1)); - } -} - -public sealed class ItemManager -{ - public static ItemManager Instance { get; } = new ItemManager(); - - private Dictionary _items = new(); - private HashSet _removedBaseItems = new(); - - private ItemManager() - { - - } - - public void Initialize() - { - - } - - public void AddItem(Item item) - { - _items[item.Id] = item; - - TryInjectIntoGameCache(item.Id, item); - } - - public void DeleteItem(Item item) - { - _items.Remove(item.Id); - - if (item.ModId == "base") - _removedBaseItems.Add(item.Id); - - RemoveFromGameCache(item.Id); - } - - public void PatchItem(Item item) - { - DeleteItem(item); - AddItem(item); - } - - public Item? GetItem(string fullId) - { - var rc = UnityEngine.Object.FindObjectOfType(); - - // Normalize "base:foo" → "foo" - var lookupId = fullId.StartsWith("base:", StringComparison.OrdinalIgnoreCase) - ? fullId.Substring(5) - : fullId; - - if (_items.TryGetValue(fullId, out var modItem)) - return modItem; - - if (rc.loaded_inventory_item_files != null && rc.loaded_inventory_item_files.TryGetValue(lookupId, out var gameFields)) - { - var item = ItemConverter.FromGameFields(lookupId, gameFields); - return item; - } - - return null; - } - - public bool IsBaseItemBlocked(string id) - => _removedBaseItems.Contains(id); - - // ---------- injection helpers ---------- - - void TryInjectIntoGameCache(string id, Item item) - { - var rc = UnityEngine.Object.FindObjectOfType(); - - rc.loaded_inventory_item_files[id] = ConvertItem(item); - } - - void RemoveFromGameCache(string id) - { - var rc = UnityEngine.Object.FindObjectOfType(); - - rc.loaded_inventory_item_files.Remove(id); - } - - public Il2CppSystem.Collections.Generic.Dictionary ConvertItem(Item item) - { - return ItemConverter.ToGameFields(item); - } -} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Items/Items.CraftingInjection.cs b/HAModHelper.GamePlugin/Items/Items.CraftingInjection.cs new file mode 100644 index 0000000..eb6fd89 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/Items.CraftingInjection.cs @@ -0,0 +1,123 @@ +using HarmonyLib; + +namespace HAModHelper.GamePlugin.Items.Systems; + +/// +/// Allows mods to inject custom items into any vanilla crafting station list +/// (e.g. "Crafting - Crafting Table", "Crafting - Crucible"). +/// +/// A single Harmony postfix on inventory_ctr.GetCraftList processes all +/// registered injections, so multiple mods injecting into the same list are both +/// applied correctly. Insertion is idempotent — opening the crafting menu multiple +/// times will not produce duplicate entries. +/// +public sealed class CraftingInjectionManager +{ + public static CraftingInjectionManager Instance { get; } = new(); + private CraftingInjectionManager() { } + + private sealed class InjectionEntry + { + public required string CraftListName; + public required string ItemFullId; + public string? InsertAfter; // null → append at end + } + + private readonly List _registrations = new(); + + public void Initialize() { } + + // TEST-ONLY + public void Reset() => _registrations.Clear(); + + /// + /// Inject into . + /// + /// File name of the craft list (without extension), e.g. + /// "Crafting - Crafting Table". + /// Full item id to inject, e.g. "Expansion:Gem". + /// Item name after which to insert. Pass null to + /// append at the end of the list. The search uses item_name (full id for + /// mod items, plain name for base-game items). + public void Inject(string craftListName, string itemFullId, string? insertAfter = null) + { + _registrations.Add(new InjectionEntry + { + CraftListName = craftListName, + ItemFullId = itemFullId, + InsertAfter = insertAfter, + }); + } + + /// Called by the Harmony patch on every GetCraftList invocation. + internal void ProcessInjections(string craftListName, ref new_craft_list result) + { + if (result?.items == null) return; + + var pending = _registrations + .Where(r => r.CraftListName == craftListName) + .ToList(); + if (pending.Count == 0) return; + + // Work on a local reference so each injection builds on the previous result. + var current = result.items; + + foreach (var entry in pending) + { + // Idempotent: skip if already present (handles repeated GetCraftList calls + // when the game caches the list object across menu opens). + bool alreadyPresent = false; + for (int i = 0; i < current.Length; i++) + { + if (current[i]?.item?.item_name == entry.ItemFullId) + { + alreadyPresent = true; + break; + } + } + if (alreadyPresent) continue; + + // Locate insertion point. + int insertAt = current.Length; // default: append + if (entry.InsertAfter != null) + { + for (int i = 0; i < current.Length; i++) + { + if (current[i]?.item?.item_name == entry.InsertAfter) + { + insertAt = i + 1; + break; + } + } + } + + // Build expanded array. + var next = new ItemCountPair[current.Length + 1]; + for (int i = 0; i < insertAt; i++) + next[i] = current[i]; + + int nCraft = 1; + try { nCraft = inventory_ctr.Instance?.GetItem_nCraft(entry.ItemFullId) ?? 1; } catch { } + next[insertAt] = new ItemCountPair(new InventoryItem(entry.ItemFullId), nCraft); + + for (int i = insertAt; i < current.Length; i++) + next[i + 1] = current[i]; + + current = next; + } + + result.items = current; + } +} + +// ── Harmony patch ───────────────────────────────────────────────────────────── + +[HarmonyPatch(typeof(inventory_ctr), nameof(inventory_ctr.GetCraftList))] +internal static class GetCraftListInjectionPatch +{ + [HarmonyPostfix] + static void Postfix(string craft_list_file_name, ref new_craft_list __result) + { + CraftingInjectionManager.Instance.ProcessInjections(craft_list_file_name, ref __result); + } +} diff --git a/HAModHelper.GamePlugin/Items/Items.Events.cs b/HAModHelper.GamePlugin/Items/Items.Events.cs new file mode 100644 index 0000000..b297792 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/Items.Events.cs @@ -0,0 +1 @@ +namespace HAModHelper.GamePlugin.Items.Events; \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Items/Items.Interfaces.cs b/HAModHelper.GamePlugin/Items/Items.Interfaces.cs new file mode 100644 index 0000000..9db8329 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/Items.Interfaces.cs @@ -0,0 +1,57 @@ +using System.Diagnostics.CodeAnalysis; +using HAModHelper.GamePlugin.Helpers; +using HAModHelper.GamePlugin.Items.Systems; + +namespace HAModHelper.GamePlugin.Items.Interfaces; +public interface IResourceControl +{ + Dictionary> loaded_inventory_item_files { get; set; } + + public bool GetItem(string id, [NotNullWhen(true)] out Dictionary? outFields) + { + if (loaded_inventory_item_files.TryGetValue(id, out var fields)) + { + outFields = fields; + return true; + } + outFields = null; + return false; + } + + public void SetItem(string id, Dictionary fields) + { + loaded_inventory_item_files[id] = fields; + } + + public void RemoveItem(string id) + { + loaded_inventory_item_files.Remove(id); + } +} + +// runtime adapter that wraps the real game ResourceControl +public class UnityResourceControl : IResourceControl +{ + private readonly ResourceControl _rc; + public UnityResourceControl(ResourceControl rc) => _rc = rc; + public Dictionary> loaded_inventory_item_files + { + get => ItemConverter.NormalizeHybridItemDictionary(_rc.loaded_inventory_item_files); + set { _rc.loaded_inventory_item_files = ItemConverter.DenormalizeHybridItemDictionary(value); } + } + + public void SetItem(string id, Dictionary fields) + { + _rc.loaded_inventory_item_files[id] = DictHelper.DenormalizeIL2CPPDictionary(fields); + } + + public void RemoveItem(string id) + { + _rc.loaded_inventory_item_files.Remove(id); + } +} + +public class DebugNoLoadResourceControl : IResourceControl +{ + public Dictionary> loaded_inventory_item_files { get; set; } = new(); +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Items/Items.Systems.cs b/HAModHelper.GamePlugin/Items/Items.Systems.cs new file mode 100644 index 0000000..d2762b3 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/Items.Systems.cs @@ -0,0 +1,289 @@ +using HAModHelper.GamePlugin.Core; +using HAModHelper.GamePlugin.Helpers; +using HAModHelper.GamePlugin.Items.Interfaces; + +namespace HAModHelper.GamePlugin.Items.Systems; + +// Every item loaded in HA has a relevant class instantiated. There will be a HashSet available for access by id. +// Modifying an item's Item class will modify it at runtime in-game once UpdateItem() is called on the item. +// Instantiating a new Item is totally legal and will cause it to become available ingame. +public class Item +{ + public required string ModId { get; set; } // melon mod id or "base" + public required string ItemId { get; set; } + public string Id => $"{ModId}:{ItemId}"; + + public required string Name { get; set; } + public string? Description { get; set; } + public int StackLimit { get; set; } = 1; + public ItemActions Actions { get; set; } = 0; // UNUSED AS OF YET UNTIL I FIND A WAY TO PATCH THE NECCESARY FUNCTIONS!! + public string? SpritePath { get; set; } + + // Escape hatch for anything not modeled yet (including keys with spaces) + public Dictionary ExtraFields { get; set; } = new(); + + public void UpdateItem() + { + var iman = ItemManager.Instance; + iman.PatchItem(this); + } +} + +[Flags] +public enum ItemActions +{ + IsTool = 1 << 0, + IsUsable = 1 << 1, + IsConsumable = 1 << 2, + IsPlaceable = 1 << 3, +} + +public static class ItemConverter +{ + public static Dictionary> NormalizeHybridItemDictionary(Il2CppSystem.Collections.Generic.Dictionary> dict) + { + var d = new Dictionary>(); + foreach (var kvp in dict) + d[kvp.Key] = DictHelper.NormalizeIL2CPPDictionary(kvp.Value); + return d; + } + + public static Il2CppSystem.Collections.Generic.Dictionary> DenormalizeHybridItemDictionary(Dictionary> dict) + { + var d = new Il2CppSystem.Collections.Generic.Dictionary>(); + foreach (var kvp in dict) + { + d[kvp.Key] = DictHelper.DenormalizeIL2CPPDictionary(kvp.Value); + } + return d; + } + public static Dictionary ToGameFields(Item item) + { + var d = new Dictionary(); + + // Sprite path (real key) + if (!string.IsNullOrWhiteSpace(item.SpritePath)) + d["Inventory_sprite_path"] = item.SpritePath; + + d["Name"] = item.Name ?? "Modded Item"; + + d["Max_stack"] = item.StackLimit.ToString(); + + if (!string.IsNullOrWhiteSpace(item.Description)) + d["Description"] = item.Description; + + // Extra fields override everything else (modder wins). + foreach (var (k, v) in item.ExtraFields) + { + if (string.IsNullOrWhiteSpace(k)) continue; + d[k] = v ?? ""; + } + + return d; + } + + // Optional helper: turn a game dict back into an Item (useful for GetItem(base:...)) (actually only for that) + public static Item FromGameFields(string fullId, Dictionary fields) + { + var (modId, id) = SplitFullId(fullId); + + var item = new Item + { + ModId = modId, + ItemId = id, + Name = fields.TryGetValue("Name", out var name) ? name : id, + }; + + if (fields.TryGetValue("Inventory_sprite_path", out var sprite)) + item.SpritePath = sprite; + + if (fields.TryGetValue("Description", out var desc)) + item.Description = desc; + + if (fields.TryGetValue("Max_stack", out var stackStr) && int.TryParse(stackStr, out var stack)) + item.StackLimit = stack; + + // Everything else goes into ExtraFields (including keys with spaces) + foreach (var kvp in fields) + { + // Skip ones we modeled above + if (kvp.Key == "Inventory_sprite_path") continue; + if (kvp.Key == "Name") continue; + if (kvp.Key == "Description") continue; + + item.ExtraFields[kvp.Key] = kvp.Value; + } + + return item; + } + + public static (string modId, string id) SplitFullId(string fullId) + { + var idx = fullId.IndexOf(':'); + if (idx <= 0) return ("base", fullId); + return (fullId.Substring(0, idx), fullId.Substring(idx + 1)); + } +} + +public sealed class ItemManager +{ + public static ItemManager Instance { get; } = new ItemManager(); + + private Dictionary _items = new(); + private Dictionary _queuedItems = new(); + private HashSet _removedBaseItems = new(); + + // TEST-ONLY: Spoof a fake ResourceControl for HAModHelper.Tests to use + public IResourceControl? DebugResourceControlSource { get; set; } + private ItemManager() { } + + // helper used by methods to obtain a proxy object + private IResourceControl? GetResourceControl() + { + if (DebugResourceControlSource?.GetType() == typeof(DebugNoLoadResourceControl)) + return null; // don't + + if (DebugResourceControlSource != null) + { + return DebugResourceControlSource; + } + + // runtime path: try to locate the game object + try + { + var rc = UnityEngine.Object.FindObjectOfType(); + if (rc == null) return null; + return new UnityResourceControl(rc); + } + catch (Exception) + { + return null; + } + } + + // TEST-ONLY: Reset system state. + public void Reset() + { + _items = []; + _queuedItems = []; + _removedBaseItems = []; + DebugResourceControlSource = null; + } + + public void Initialize() + { + } + + public void AddItem(Item item) + { + _items[item.Id] = item; + + TryInjectIntoGameCache(item.Id, item); + } + + public void DeleteItem(Item item) + { + _items.Remove(item.Id); + + if (item.ModId == "base") + _removedBaseItems.Add(item.ItemId); + + RemoveFromGameCache(item.Id); + } + + public void PatchItem(Item item) + { + DeleteItem(item); + AddItem(item); + } + + public Item? GetItem(string fullId) + { + var rcProxy = GetResourceControl(); + + if (rcProxy == null) + { + return null; + } + + // Normalize "base:foo" → "foo" + var lookupId = fullId.StartsWith("base:", StringComparison.OrdinalIgnoreCase) + ? fullId.Substring(5) + : fullId; + + if (_items.TryGetValue(fullId, out var modItem)) + return modItem; + + if (rcProxy.GetItem(lookupId, out var gameFields)) + { + var item = ItemConverter.FromGameFields(lookupId, gameFields); + return item; + } + + return null; + } + + public bool IsBaseItem(string id) + { + var split = ItemConverter.SplitFullId(id); + return split.modId == "base"; + } + + public bool IsBaseItemBlocked(string id) + => _removedBaseItems.Contains(id); + + // ---------- injection helpers ---------- + + public void TryInjectIntoGameCache(string id, Item item) + { + var rcProxy = GetResourceControl(); + if (rcProxy == null) + { + try { HAMHMod.Logger.LogInfo($"[HAMH] ResourceControl not ready, queuing item {id}"); } catch { } + _queuedItems[id] = item; + return; + } + + rcProxy.SetItem(id, ConvertItem(item)); + } + + public void RemoveFromGameCache(string id) + { + var rcProxy = GetResourceControl(); + if (rcProxy == null) + return; + + rcProxy.RemoveItem(id); + } + + public void ProcessQueuedItems() + { + var processedItem = false; + var watch = System.Diagnostics.Stopwatch.StartNew(); + foreach (var kvp in _queuedItems) + { + processedItem = true; + try + { + HAMHMod.Logger.LogInfo($"[HAMH] Processing queued item {kvp.Key}"); + } + catch { } + ; + TryInjectIntoGameCache(kvp.Key, kvp.Value); + } + _queuedItems.Clear(); + watch.Stop(); + if (processedItem) + try + { + HAMHMod.Logger.LogInfo($"[HAMH] Processed queued items in {watch.ElapsedMilliseconds}ms."); + } + catch { } + ; + } + + public Dictionary ConvertItem(Item item) + { + return ItemConverter.ToGameFields(item); + } +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Items/Items.WorldPrefab.cs b/HAModHelper.GamePlugin/Items/Items.WorldPrefab.cs new file mode 100644 index 0000000..ff87994 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/Items.WorldPrefab.cs @@ -0,0 +1,137 @@ +using HarmonyLib; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace HAModHelper.GamePlugin.Items.Systems; + +/// +/// Allows mods to register custom AssetBundle prefabs for world-placeable items whose +/// World_obj_path is not present in the game's Addressables catalog. +/// +/// Patches both overloads of ResourceControl.AsyncInstantiateWorldObjectPrefab: +/// the overload (initial placement / move via CreateMouseObj) +/// and the string overload (move via ResetDevMouseObject / TryEnterBuildMode). +/// All registered mods are checked in a single shared patch — multiple mods can safely +/// register prefabs at the same time. +/// +public sealed class WorldPrefabManager +{ + public static WorldPrefabManager Instance { get; } = new(); + private WorldPrefabManager() { } + + private sealed class PrefabEntry + { + public required AssetBundle Bundle; + public required string AssetName; + public GameObject? Cache; + } + + // keyed by full item id ("Expansion:Gem") + private readonly Dictionary _byItemId = new(); + // keyed by World_obj_path ("Prefabs/Expansion/gem") — shared entry when path is reused + private readonly Dictionary _byObjPath = new(); + + public void Initialize() { } + + /// + /// Register a custom world prefab. + /// + /// Full mod item id, e.g. "MyMod:MyItem". + /// The value set in the item's World_obj_path field. + /// AssetBundle that contains the prefab. Keep a static reference + /// in your plugin so the native backing is never freed. + /// Name of the prefab asset inside the bundle. + public void Register(string itemFullId, string worldObjPath, AssetBundle bundle, string assetName) + { + var entry = new PrefabEntry { Bundle = bundle, AssetName = assetName }; + _byItemId[itemFullId] = entry; + _byObjPath[worldObjPath] = entry; + } + + // Called by the InventoryItem-overload patch. + internal bool TryInvokeByItemId(string itemFullId, Il2CppSystem.Action callback) + => _byItemId.TryGetValue(itemFullId, out var entry) && Invoke(entry, callback); + + // Called by the string-overload patch. + internal bool TryInvokeByObjPath(string worldObjPath, Il2CppSystem.Action callback) + => _byObjPath.TryGetValue(worldObjPath, out var entry) && Invoke(entry, callback); + + private static bool Invoke(PrefabEntry entry, Il2CppSystem.Action callback) + { + if (entry.Cache == null) + { + var asset = entry.Bundle.LoadAsset(entry.AssetName); + if (asset == null) return false; + entry.Cache = asset.Cast(); + } + + // Instantiate a fresh copy: the game's CreateMouseObj b__0 callback mutates + // the received object in-place and calls Object.Destroy on it when the player + // exits build mode — passing the prefab directly would destroy the source asset. + var instance = Object.Instantiate(entry.Cache); + callback?.Invoke(instance); + return true; + } +} + +// ── Harmony patches ────────────────────────────────────────────────────────── + +/// +/// Intercepts ResourceControl.AsyncInstantiateWorldObjectPrefab(InventoryItem, Chunk, Action) +/// (the placement path: CreateMouseObj → EnterBuildMode → GrabFurniture). +/// Uses [HarmonyTargetMethod] because Il2CppSystem.Action<GameObject> +/// cannot be resolved from attribute syntax at compile time in an IL2CPP context. +/// +[HarmonyPatch] +internal static class WorldPrefabItemOverloadPatch +{ + [HarmonyTargetMethod] + static System.Reflection.MethodBase TargetMethod() + { + foreach (var m in typeof(ResourceControl).GetMethods( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + if (m.Name != "AsyncInstantiateWorldObjectPrefab") continue; + var p = m.GetParameters(); + if (p.Length == 3 && p[0].ParameterType == typeof(InventoryItem)) + return m; + } + return null!; + } + + [HarmonyPrefix] + static bool Prefix(InventoryItem item, Il2CppSystem.Action on_asset_ready) + { + if (item == null) return true; + return !WorldPrefabManager.Instance.TryInvokeByItemId(item.item_name, on_asset_ready); + } +} + +/// +/// Intercepts ResourceControl.AsyncInstantiateWorldObjectPrefab(string, Chunk, Action) +/// (the move path: ResetDevMouseObject / TryEnterBuildMode). +/// +[HarmonyPatch] +internal static class WorldPrefabStringOverloadPatch +{ + [HarmonyTargetMethod] + static System.Reflection.MethodBase TargetMethod() + { + foreach (var m in typeof(ResourceControl).GetMethods( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + if (m.Name != "AsyncInstantiateWorldObjectPrefab") continue; + var p = m.GetParameters(); + if (p.Length == 3 && p[0].ParameterType == typeof(string)) + return m; + } + return null!; + } + + [HarmonyPrefix] + static bool Prefix(string obj_path, Il2CppSystem.Action on_asset_ready) + { + if (string.IsNullOrEmpty(obj_path)) return true; + return !WorldPrefabManager.Instance.TryInvokeByObjPath(obj_path, on_asset_ready); + } +} diff --git a/HAModHelper.GamePlugin/Perks/Perks.Events.cs b/HAModHelper.GamePlugin/Perks/Perks.Events.cs new file mode 100644 index 0000000..636afb3 --- /dev/null +++ b/HAModHelper.GamePlugin/Perks/Perks.Events.cs @@ -0,0 +1,11 @@ +using HAModHelper.GamePlugin.Base.Events; + +namespace HAModHelper.GamePlugin.Perks.Events; +public class PerkCastEvent : BaseEvent +{ + public string PerkId { get; } + public PerkCastEvent(string perkId) + { + PerkId = perkId; + } +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Perks/Perks.Interfaces.cs b/HAModHelper.GamePlugin/Perks/Perks.Interfaces.cs new file mode 100644 index 0000000..581c2b7 --- /dev/null +++ b/HAModHelper.GamePlugin/Perks/Perks.Interfaces.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; +using HAModHelper.GamePlugin.Helpers; + +namespace HAModHelper.GamePlugin.Perks.Interfaces; + +public interface IPerkControl +{ + Dictionary loaded_perks { get; set; } + + public bool GetPerk(string id, [NotNullWhen(true)] out PerkData? outData) + { + if (loaded_perks.TryGetValue(id, out var data)) + { + outData = data; + return true; + } + outData = null; + return false; + } + + public void SetPerk(string id, PerkData data) + { + loaded_perks[id] = data; + } + + public void RemovePerk(string id) + { + loaded_perks.Remove(id); + } +} + +// runtime adapter that wraps the real game PerkControl +public class UnityPerkControl : IPerkControl +{ + private readonly PerkControl _pc; + public UnityPerkControl(PerkControl pc) => _pc = pc; + + public Dictionary loaded_perks + { + get => DictHelper.NormalizeIL2CPPDictionary(_pc.loaded_perks); + set { _pc.loaded_perks = DictHelper.DenormalizeIL2CPPDictionary(value); } + } + + public void SetPerk(string id, PerkData data) + { + _pc.loaded_perks[id] = data; + } + + public void RemovePerk(string id) + { + _pc.loaded_perks.Remove(id); + } +} + +public class DebugNoLoadPerkControl : IPerkControl +{ + public Dictionary loaded_perks { get; set; } = new(); +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Perks/Perks.Systems.cs b/HAModHelper.GamePlugin/Perks/Perks.Systems.cs new file mode 100644 index 0000000..a54bb9e --- /dev/null +++ b/HAModHelper.GamePlugin/Perks/Perks.Systems.cs @@ -0,0 +1,237 @@ +using HAModHelper.GamePlugin.Core; +using HAModHelper.GamePlugin.Helpers; +using HAModHelper.GamePlugin.Perks.Interfaces; + +namespace HAModHelper.GamePlugin.Perks.Systems; + +public class Perk +{ + public string ModId { get; set; } = "base"; + public string PerkId { get; set; } = ""; + public string Id => $"{ModId}:{PerkId}"; + + public required string Name { get; set; } + public required string Description { get; set; } + public required string DetailedDescription { get; set; } + public string? UltraDetailedDescription { get; set; } + public string? SpritePath { get; set; } + public Dictionary? PerkEffects { get; set; } +} + +public static class PerkConverter +{ + public static PerkData ToPerkData(Perk perk) + { + var pdata = new PerkData(); + + pdata.full_name = perk.Name ?? "Modded Perk"; + + if (!string.IsNullOrWhiteSpace(perk.Description)) + pdata.description = perk.Description; + + if (!string.IsNullOrWhiteSpace(perk.DetailedDescription)) + pdata.detailed_description = perk.DetailedDescription; + + if (perk.PerkEffects != null) + pdata.all_effects = DictHelper.DenormalizeIL2CPPDictionary(perk.PerkEffects); + + return pdata; + } + + public static Perk FromPerkData(string fullId, PerkData data) + { + var (modId, id) = SplitFullId(fullId); + + var perk = new Perk + { + ModId = modId, + PerkId = id, + Name = data.full_name, + Description = data.description, + DetailedDescription = data.detailed_description, + PerkEffects = DictHelper.NormalizeIL2CPPDictionary(data.all_effects) + }; + + return perk; + } + + /// Splits a full ID like "mod:perkname" into (mod, perkname). + public static (string ModId, string PerkId) SplitFullId(string fullId) + { + var colonIndex = fullId.IndexOf(':'); + if (colonIndex > 0) + { + return (fullId.Substring(0, colonIndex), fullId.Substring(colonIndex + 1)); + } + return ("base", fullId); + } +} + +public sealed class PerkManager +{ + public static PerkManager Instance => new PerkManager(); + + private Dictionary _perks = new(); + private Dictionary _queuedPerks = new(); + private HashSet _removedBasePerks = new(); + + // TEST-ONLY: Spoof a fake PerkControl for tests + public IPerkControl? DebugPerkControlSource { get; set; } + + private PerkManager() { } + private IPerkControl? GetPerkControl() + { + if (DebugPerkControlSource?.GetType() == typeof(DebugNoLoadPerkControl)) + return null; + + if (DebugPerkControlSource != null) + { + return DebugPerkControlSource; + } + + // runtime path: try to locate the game object + try + { + var pc = UnityEngine.Object.FindObjectOfType(); + if (pc == null) return null; + return new UnityPerkControl(pc); + } + catch (Exception) + { + return null; + } + } + + /// TEST-ONLY: Reset system state. + public void Reset() + { + _perks = []; + _queuedPerks = []; + _removedBasePerks = []; + DebugPerkControlSource = null; + } + + /// Initialize the perk manager (called on game start). + public void Initialize() + { + } + + /// Add a perk to the system. + public void AddPerk(Perk perk) + { + _perks[perk.Id] = perk; + TryInjectIntoGameCache(perk.Id, perk); + } + + /// Delete a perk from the system. + public void DeletePerk(Perk perk) + { + _perks.Remove(perk.Id); + + if (perk.ModId == "base") + _removedBasePerks.Add(perk.PerkId); + + RemoveFromGameCache(perk.Id); + } + + /// Patch (update) an existing perk. + public void PatchPerk(Perk perk) + { + DeletePerk(perk); + AddPerk(perk); + } + + /// Get a perk by its full ID (e.g., "mod:perkid"). + public Perk? GetPerk(string fullId) + { + var pcProxy = GetPerkControl(); + + if (pcProxy == null) + { + return null; + } + + // Normalize "base:foo" → "foo" + var lookupId = fullId.StartsWith("base:", StringComparison.OrdinalIgnoreCase) + ? fullId.Substring(5) + : fullId; + + if (_perks.TryGetValue(fullId, out var modPerk)) + return modPerk; + + if (pcProxy.GetPerk(lookupId, out var perkData)) + { + var perk = PerkConverter.FromPerkData(lookupId, perkData); + return perk; + } + + return null; + } + + /// Check if a perk ID is a base game perk. + public bool IsBasePerk(string id) + { + return !_perks.ContainsKey(id); + } + + /// Check if a base perk has been blocked/removed. + public bool IsBasePerkBlocked(string id) + => _removedBasePerks.Contains(id); + + // ---------- injection helpers ---------- + + /// Try to inject a perk into the game cache, queueing if needed. + public void TryInjectIntoGameCache(string id, Perk perk) + { + var pcProxy = GetPerkControl(); + if (pcProxy == null) + { + try { HAMHMod.Logger.LogInfo($"[HAMH] PerkControl not ready, queuing perk {id}"); } catch { } + _queuedPerks[id] = perk; + return; + } + + pcProxy.SetPerk(id, ConvertPerk(perk)); + } + + /// Remove a perk from the game cache. + public void RemoveFromGameCache(string id) + { + var pcProxy = GetPerkControl(); + if (pcProxy == null) + return; + + pcProxy.RemovePerk(id); + } + + /// Process any perks that were queued waiting for game initialization. + public void ProcessQueuedPerks() + { + var processedPerk = false; + var watch = System.Diagnostics.Stopwatch.StartNew(); + foreach (var kvp in _queuedPerks) + { + processedPerk = true; + try + { + HAMHMod.Logger.LogInfo($"[HAMH] Processing queued perk {kvp.Key}"); + } + catch { } + TryInjectIntoGameCache(kvp.Key, kvp.Value); + } + _queuedPerks.Clear(); + watch.Stop(); + if (processedPerk) + try + { + HAMHMod.Logger.LogInfo($"[HAMH] Processed queued perks in {watch.ElapsedMilliseconds}ms."); + } + catch { } + } + + /// Convert a Perk object to game field dictionary. + public PerkData ConvertPerk(Perk perk) + { + return PerkConverter.ToPerkData(perk); + } +} \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Program.cs b/HAModHelper.GamePlugin/Program.cs index 0bd39c5..c18d16c 100644 --- a/HAModHelper.GamePlugin/Program.cs +++ b/HAModHelper.GamePlugin/Program.cs @@ -1,5 +1,2 @@ -using MelonLoader; using HAModHelper.GamePlugin.Core; // The namespace of your mod class -[assembly: MelonInfo(typeof(HAMHMod), "HAModHelper", "v0.0.1", "Eris (erisws)")] -[assembly: MelonGame("Abstract Softwares", "HAModLoader")] \ No newline at end of file diff --git a/HAModHelper.GamePlugin/Resources/UniverseLib.BIE.IL2CPP.Interop.dll b/HAModHelper.GamePlugin/Resources/UniverseLib.BIE.IL2CPP.Interop.dll new file mode 100644 index 0000000..0202391 Binary files /dev/null and b/HAModHelper.GamePlugin/Resources/UniverseLib.BIE.IL2CPP.Interop.dll differ diff --git a/HAModHelper.GamePlugin/World/World.Systems.cs b/HAModHelper.GamePlugin/World/World.Systems.cs new file mode 100644 index 0000000..5e86b1e --- /dev/null +++ b/HAModHelper.GamePlugin/World/World.Systems.cs @@ -0,0 +1,569 @@ +using HarmonyLib; +using HAModHelper.GamePlugin.Core; + +namespace HAModHelper.GamePlugin.World.Systems; + +// --------------------------------------------------------------------------- +// Hybrid Animals world generation (reverse-engineered from Assembly-CSharp): +// +// ChunkControl.HostGetChunk loads a chunk from disk if it exists, otherwise calls +// ChunkGeneratorOverworld.GenerateUnexplored(chunk_data, X, Z, zone, biome_id, ...) ONCE for the +// new chunk and then SaveWholeChunkToDisk persists it. So anything added during generation is saved +// like vanilla terrain and never re-rolled. +// +// GenerateUnexplored looks the biome up by biome_id in ChunkControl.Instance.biomes[] and calls the +// private GenerateBiomeObjects twice (scenic + item layer), passing the biome's +// ChunkControl.biome_obj[] (biome_scenic). That method weights the candidates by spawn_commonness, +// applies native clumping (additional_clump_min/max), checks empty tiles, and commits objects with +// ChunkData.AddElement — attaching a unique basket_id (ConstructionControl.GetNewUniqueId) to each. +// +// Each chunk is a 10x10 inner grid (inner coords 0..9). Biome ids (ChunkControl.GenBlobBiometype): +// grass=0, snow=1, desert=2, evergreen=3, ocean=4, swamp=6, woodlands=8, sakura=9. +// +// IMPORTANT (why this file does NOT patch GenerateBiomeObjects): under BepInEx/Il2CppInterop, Harmony +// builds a native<->managed trampoline for every patched method, marshalling the full original +// signature. GenerateBiomeObjects takes a bool[,] and other types that can't be marshalled, and even +// patching GenerateUnexplored is sensitive — declaring a managed `string` parameter on the patch makes +// the trampoline fail to marshal the method's string args, which throws on EVERY chunk and leaves the +// whole world empty. So: the GenerateUnexplored patches below take ONLY blittable/safe parameters +// (no strings), and WEIGHTED injection is done by mutating the biome's native biome_scenic list once +// (a plain field/array operation, no method patch), letting vanilla generation do the weighting. +// --------------------------------------------------------------------------- + +/// The overworld biomes, keyed by the game's internal biome_id. +public enum Biome +{ + /// Matches every biome. Only valid for injections. + Any = -1, + + Grass = 0, + Snow = 1, + Desert = 2, + Evergreen = 3, + Ocean = 4, + Swamp = 6, + Woodlands = 8, + Sakura = 9, +} + +/// +/// How common a object is in its biome. Mirrors the game's +/// ChunkControl.spawn_commonness exactly: higher rarity = picked less often; Double/Triple make +/// it appear more often than Average. +/// +public enum WeightedCommonness +{ + Average = 0, + RareSlight = 1, + RareMedium = 2, + RareVery = 3, + Double = 4, + Triple = 5, +} + +/// Which mechanism a world-gen registration uses. +public enum WorldGenInjectionType +{ + /// + /// Added to the biome's native weighted object list, so the game spawns it with its own weighting, + /// clumping, empty-tile checks and budget — exactly like a vanilla scenic/world object. + /// + Weighted, + + /// + /// A rare, per-chunk guaranteed-style spawn placed in clusters (like the titanium chests), driven by + /// rather than the biome's weighted budget. + /// + Special, + + /// + /// No built-in placement — runs once per freshly + /// generated chunk of the target biome with full control (use to place). + /// + Custom, + + /// + /// Removes (vanilla or modded) from the target biome's + /// generated object list, so it stops spawning there. + /// + Remove, +} + +/// +/// Context handed to a generator for one freshly generated +/// chunk. Place objects with ; probe free tiles with . +/// +public readonly struct WorldGenContext(ChunkData chunkData, int chunkX, int chunkZ, string zone, int biomeId, bool isQuestMiniworld) +{ + /// The chunk being generated. + public ChunkData ChunkData { get; } = chunkData; + + /// Chunk X coordinate. + public int ChunkX { get; } = chunkX; + + /// Chunk Z coordinate. + public int ChunkZ { get; } = chunkZ; + + /// The zone (overworld dimension) the chunk belongs to. + public string Zone { get; } = zone; + + /// The biome id of the chunk. + public int BiomeId { get; } = biomeId; + + /// True if this is a hand-built quest mini-world (you usually want to skip those). + public bool IsQuestMiniworld { get; } = isQuestMiniworld; + + /// True if the inner tile (x, z) is currently unoccupied. Inner coords are 0..9. + public bool EmptyAt(int x, int z) => ChunkData != null && ChunkData.EmptyAt(x, z); + + /// + /// Place at inner tile (x, z), mirroring how the game commits a generated + /// object (a fresh InventoryItem carrying a unique basket_id). Returns false if the tile is occupied. + /// + public bool TryPlace(string itemName, int x, int z, int rot = 0) + => WorldGenManager.PlaceObject(ChunkData, itemName, x, z, rot); +} + +/// One registered world-gen entry: an item id (or callback) plus how/where it should generate. +public sealed class WorldGenRegistration +{ + /// Which mechanism this registration uses. + public required WorldGenInjectionType Type { get; init; } + + /// The biome to act on ( only for Custom). + public required Biome Biome { get; init; } + + /// + /// The full item id (e.g. "MyMod:MyRock"). Required for Weighted, Special and Remove; + /// ignored for Custom. + /// + public string? ItemName { get; init; } + + // ── Weighted ──────────────────────────────────────────────────────────────── + /// Weighted: how common the object is. Defaults to . + public WeightedCommonness Commonness { get; init; } = WeightedCommonness.Average; + + /// + /// Weighted: whether the object belongs to the item layer (true, the default — placeable world + /// objects such as veins/chests) or the scenic layer (false — purely decorative props). + /// + public bool AsItem { get; init; } = true; + + /// Weighted/Special: minimum extra copies placed next to the first (a clump). Defaults to 0. + public int ClumpMin { get; init; } + + /// Weighted/Special: maximum extra copies placed next to the first. Defaults to 0. + public int ClumpMax { get; init; } + + /// Weighted: minimum world depth required for the object to appear. Defaults to 0. + public float MinDepth { get; init; } + + /// Weighted/Special: whether the object may be randomly rotated. Defaults to true. + public bool Rotate { get; init; } = true; + + // ── Special ───────────────────────────────────────────────────────────────── + /// Special: probability (0..1) that a generated chunk of this biome gets a cluster. + public double ChancePerChunk { get; init; } = 0.25; + + /// + /// Special: the object's footprint in tiles (NxN). A cluster member is only placed where a full + /// NxN block of inner tiles is free. Defaults to 1. + /// + public int Footprint { get; init; } = 1; + + // ── Special / Custom ───────────────────────────────────────────────────────── + /// Special/Custom: skip hand-built quest mini-worlds. Defaults to true. + public bool SkipQuestMiniworlds { get; init; } = true; + + /// Custom: the generator invoked once per freshly generated chunk of the target biome. + public Action? OnGenerate { get; init; } +} + +/// +/// Registers objects (or behaviours) into Hybrid Animals world generation. Populate it with the +/// Inject*/Remove* methods; the Harmony patches in this file (applied by the helper's +/// Harmony.PatchAll) consult it live, so registrations made at any time take effect. Reusable — +/// nothing here is specific to any one mod. +/// +public sealed class WorldGenManager +{ + public static WorldGenManager Instance { get; } = new(); + private WorldGenManager() { } + + private readonly List _registrations = new(); + + // Weighted injection is applied by mutating each biome's native biome_scenic list. We keep the + // pristine original per biome so re-applying (e.g. after a late registration) rebuilds from scratch + // instead of stacking, and a signature so we only re-mutate when the relevant registrations change. + private readonly Dictionary _originalScenic = new(); + private readonly Dictionary _appliedSignature = new(); + + public void Initialize() { } + + // TEST-ONLY: reset system state. + public void Reset() + { + _registrations.Clear(); + _originalScenic.Clear(); + _appliedSignature.Clear(); + } + + /// Register (or add) a world-gen entry. Returns the registration so it can later be passed + /// to . Safe to call at any time. + public WorldGenRegistration Register(WorldGenRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + + bool needsItem = registration.Type != WorldGenInjectionType.Custom; + if (needsItem && string.IsNullOrEmpty(registration.ItemName)) + throw new ArgumentException($"{registration.Type} world-gen injections require an ItemName.", nameof(registration)); + if (registration.Type == WorldGenInjectionType.Custom && registration.OnGenerate is null) + throw new ArgumentException("Custom world-gen injections require an OnGenerate handler.", nameof(registration)); + if (registration.Type != WorldGenInjectionType.Custom && registration.Biome == Biome.Any) + throw new ArgumentException($"{registration.Type} world-gen injections require a specific Biome (not Biome.Any).", nameof(registration)); + + _registrations.Add(registration); + return registration; + } + + /// Remove a registration previously returned by one of the Inject*/Register methods. + public void Unregister(WorldGenRegistration registration) + { + if (registration != null) _registrations.Remove(registration); + } + + /// + /// Inject an object into a biome's native weighted generation. The game handles weighting, + /// clumping, empty-tile checks, budget and saving — exactly like a vanilla world object. + /// + /// Full item id, e.g. "MyMod:IceVein". + /// Which biome to spawn it in. + /// How common it is relative to the biome's other objects. + /// Minimum extra copies clumped next to each placement (0 = single). + /// Maximum extra copies clumped next to each placement. + /// True for placeable world objects (default), false for decorative scenics. + /// Minimum world depth required before it appears. + /// Whether it may be randomly rotated. + public WorldGenRegistration InjectWeighted( + string itemName, Biome biome, WeightedCommonness commonness = WeightedCommonness.Average, + int clumpMin = 0, int clumpMax = 0, bool asItem = true, float minDepth = 0f, bool rotate = true) + => Register(new WorldGenRegistration + { + Type = WorldGenInjectionType.Weighted, + Biome = biome, + ItemName = itemName, + Commonness = commonness, + ClumpMin = clumpMin, + ClumpMax = clumpMax, + AsItem = asItem, + MinDepth = minDepth, + Rotate = rotate, + }); + + /// + /// Inject an object as a rare, per-chunk special spawn (titanium-chest style): each generated chunk + /// of has a probability of receiving a + /// cluster of 1 + [clumpMin..clumpMax] copies, independent of the biome's weighted budget. + /// + public WorldGenRegistration InjectSpecial( + string itemName, Biome biome, double chancePerChunk, + int clumpMin = 0, int clumpMax = 0, int footprint = 1, bool rotate = true, bool skipQuestMiniworlds = true) + => Register(new WorldGenRegistration + { + Type = WorldGenInjectionType.Special, + Biome = biome, + ItemName = itemName, + ChancePerChunk = chancePerChunk, + ClumpMin = clumpMin, + ClumpMax = clumpMax, + Footprint = footprint, + Rotate = rotate, + SkipQuestMiniworlds = skipQuestMiniworlds, + }); + + /// + /// Inject a fully custom generator: runs once per freshly generated + /// chunk of (or every biome when ), with full control. + /// + public WorldGenRegistration InjectCustom( + Biome biome, Action onGenerate, bool skipQuestMiniworlds = true) + => Register(new WorldGenRegistration + { + Type = WorldGenInjectionType.Custom, + Biome = biome, + OnGenerate = onGenerate, + SkipQuestMiniworlds = skipQuestMiniworlds, + }); + + /// + /// Stop (vanilla or modded) from generating in . + /// + public WorldGenRegistration RemoveFromBiome(string itemName, Biome biome) + => Register(new WorldGenRegistration + { + Type = WorldGenInjectionType.Remove, + Biome = biome, + ItemName = itemName, + }); + + // ── consulted by the patches ──────────────────────────────────────────────── + + private IEnumerable WeightedFor(int biomeId) + => _registrations.Where(r => r.Type == WorldGenInjectionType.Weighted && (int)r.Biome == biomeId); + + private HashSet RemovalsFor(int biomeId) + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var r in _registrations) + if (r.Type == WorldGenInjectionType.Remove && (int)r.Biome == biomeId && r.ItemName != null) + set.Add(r.ItemName); + return set; + } + + private IEnumerable SpecialFor(int biomeId) + => _registrations.Where(r => r.Type == WorldGenInjectionType.Special && (int)r.Biome == biomeId); + + private IEnumerable CustomFor(int biomeId) + => _registrations.Where(r => r.Type == WorldGenInjectionType.Custom && + (r.Biome == Biome.Any || (int)r.Biome == biomeId)); + + internal bool HasUnexploredWork(int biomeId) + => _registrations.Any(r => + (r.Type == WorldGenInjectionType.Special && (int)r.Biome == biomeId) || + (r.Type == WorldGenInjectionType.Custom && (r.Biome == Biome.Any || (int)r.Biome == biomeId))); + + private int WeightedSignature(int biomeId) + => _registrations.Count(r => + (r.Type == WorldGenInjectionType.Weighted || r.Type == WorldGenInjectionType.Remove) && + (int)r.Biome == biomeId); + + // Apply (or refresh) WEIGHTED + REMOVE registrations for a biome by mutating its native biome_scenic + // list, so vanilla GenerateBiomeObjects does the weighting/clumping for us. Idempotent and cheap; it + // only rebuilds when the relevant registrations change. Runs from the GenerateUnexplored prefix, + // before the chunk generates. Never throws out — a failure here must not break generation. + internal void ApplyWeightedFor(int biomeId) + { + int sig = WeightedSignature(biomeId); + if (_appliedSignature.TryGetValue(biomeId, out var applied) && applied == sig) return; + if (sig == 0) { _appliedSignature[biomeId] = 0; return; } + + var cc = ChunkControl.Instance; + if (cc == null) return; // not ready yet — retry on a later chunk + var biomes = cc.biomes; + if (biomes == null || biomeId < 0 || biomeId >= biomes.Length) { _appliedSignature[biomeId] = sig; return; } + + var biome = biomes[biomeId]; + if (!_originalScenic.ContainsKey(biomeId)) + _originalScenic[biomeId] = biome.biome_scenic; + + var rebuilt = BuildObjectList(_originalScenic[biomeId], biomeId); + biome.biome_scenic = rebuilt ?? _originalScenic[biomeId]; + biomes[biomeId] = biome; // struct write-back into the il2cpp array + + _appliedSignature[biomeId] = sig; + HAMHMod.Logger?.LogInfo($"[HAMH] World-gen weighted list applied for biome {biomeId} ({biome.biome_scenic?.Length ?? 0} entries)."); + } + + // Build the object list for a biome: the originals (minus removals) plus our weighted entries as real + // biome_obj structs. Returns null when nothing changes. + private ChunkControl.biome_obj[]? BuildObjectList(ChunkControl.biome_obj[]? original, int biomeId) + { + var removals = RemovalsFor(biomeId); + var additions = WeightedFor(biomeId).ToList(); + if (removals.Count == 0 && additions.Count == 0) return null; + + var kept = new List(); + if (original != null) + { + for (int i = 0; i < original.Length; i++) + { + var e = original[i]; + if (removals.Count > 0 && e.item_name != null && removals.Contains(e.item_name)) continue; + kept.Add(e); + } + } + + foreach (var a in additions) + { + kept.Add(new ChunkControl.biome_obj + { + item_name = a.ItemName, + is_item = a.AsItem, + spawn_rate = (ChunkControl.spawn_commonness)(int)a.Commonness, + additional_clump_min = a.ClumpMin, + additional_clump_max = a.ClumpMax, + clump_overwrite_obj = null, + dont_rotate = !a.Rotate, + min_depth = a.MinDepth, + hide_from_mimicry_perk = false, + }); + } + + var result = new ChunkControl.biome_obj[kept.Count]; + for (int i = 0; i < kept.Count; i++) result[i] = kept[i]; + return result; + } + + // Run SPECIAL + CUSTOM registrations for a freshly generated chunk (called from the postfix). + internal void RunUnexplored(ChunkData chunkData, int X, int Z, string zone, int biomeId, bool isQuestMiniworld) + { + foreach (var reg in SpecialFor(biomeId)) + { + try + { + if (isQuestMiniworld && reg.SkipQuestMiniworlds) continue; + if (string.IsNullOrEmpty(reg.ItemName)) continue; + + // Deterministic per-chunk-per-item RNG: stable across reloads/regeneration. + int seed = unchecked((X * 73856093) ^ (Z * 19349663) ^ reg.ItemName!.GetHashCode()); + var rng = new System.Random(seed); + if (rng.NextDouble() >= reg.ChancePerChunk) continue; + + int footprint = Math.Max(1, reg.Footprint); + int extra = reg.ClumpMax > reg.ClumpMin ? rng.Next(reg.ClumpMin, reg.ClumpMax + 1) : reg.ClumpMin; + int total = 1 + Math.Max(0, extra); + + int placed = 0, fx = 0, fz = 0; + for (int i = 0; i < total; i++) + { + bool anchored = placed > 0; + if (!TryFindSpot(chunkData, rng, footprint, anchored, fx, fz, out int vx, out int vz)) + break; + + int rot = reg.Rotate ? rng.Next(0, 4) : 0; + if (!PlaceObject(chunkData, reg.ItemName!, vx, vz, rot)) continue; + + if (placed == 0) { fx = vx; fz = vz; } + placed++; + HAMHMod.Logger?.LogInfo( + $"[HAMH] World-gen special spawned '{reg.ItemName}' in biome {biomeId} — chunk ({X},{Z}) inner ({vx},{vz}) [{placed}/{total}]"); + } + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] World-gen special injection '{reg.ItemName}' failed at chunk ({X},{Z}): {ex}"); + } + } + + foreach (var reg in CustomFor(biomeId)) + { + try + { + if (isQuestMiniworld && reg.SkipQuestMiniworlds) continue; + reg.OnGenerate?.Invoke(new WorldGenContext(chunkData, X, Z, zone, biomeId, isQuestMiniworld)); + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] World-gen custom injection failed at chunk ({X},{Z}): {ex}"); + } + } + } + + // Commit one object at inner tile (x, z) the way the game does: a fresh InventoryItem carrying a + // unique basket_id. Returns false if the tile is occupied. + internal static bool PlaceObject(ChunkData chunk, string itemName, int x, int z, int rot) + { + if (chunk == null || string.IsNullOrEmpty(itemName)) return false; + if (!chunk.EmptyAt(x, z)) return false; + + var cc = ConstructionControl.Instance; + if (cc != null) + { + var extra = new ExtraInventoryData(); + extra.SetLong("basket_id", cc.GetNewUniqueId()); + chunk.AddElement(x, z, new ChunkElement(new InventoryItem(itemName, extra), rot)); + } + else + { + chunk.AddElement(x, z, new ChunkElement(itemName, rot)); + } + return true; + } + + // Find an origin tile whose footprint*footprint centred block is empty; keep the footprint inside + // the 0..9 grid. When anchored, restrict to a few tiles from the anchor so a cluster reads together. + private static bool TryFindSpot( + ChunkData chunk, System.Random rng, int footprint, bool anchored, int anchorX, int anchorZ, + out int ox, out int oz) + { + int half = Math.Max(0, footprint / 2); + int lo = half; + int hi = 9 - half; + if (hi < lo) { ox = oz = 0; return false; } + + for (int attempt = 0; attempt < 40; attempt++) + { + int x = rng.Next(lo, hi + 1); + int z = rng.Next(lo, hi + 1); + + if (anchored) + { + int cheb = Math.Max(Math.Abs(x - anchorX), Math.Abs(z - anchorZ)); + if (cheb < footprint || cheb > footprint + 2) continue; + } + + if (FootprintEmpty(chunk, x, z, footprint)) { ox = x; oz = z; return true; } + } + + ox = oz = 0; + return false; + } + + private static bool FootprintEmpty(ChunkData chunk, int cx, int cz, int footprint) + { + int half = footprint / 2; + for (int dx = -half; dx <= half; dx++) + for (int dz = -half; dz <= half; dz++) + if (!chunk.EmptyAt(cx + dx, cz + dz)) return false; + return true; + } +} + +// ── Harmony patches (applied by the helper's Harmony.PatchAll) ────────────────── +// +// Both patches target ChunkGeneratorOverworld.GenerateUnexplored and take ONLY safe (non-string) +// parameters, so Harmony's trampoline for the method marshals cleanly. The zone string is read off the +// ChunkData field instead of taken as a patch parameter (a string patch parameter breaks marshalling +// and empties the whole world). + +/// +/// Prefix: applies WEIGHTED/REMOVE registrations to the biome's native object list before the chunk +/// generates, so vanilla generation produces them with its own weighting and clumping. +/// +[HarmonyPatch(typeof(ChunkGeneratorOverworld), nameof(ChunkGeneratorOverworld.GenerateUnexplored))] +internal static class GenerateUnexploredWeightedPatch +{ + [HarmonyPrefix] + static void Prefix(int biome_id) + { + try + { + WorldGenManager.Instance.ApplyWeightedFor(biome_id); + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] World-gen weighted apply failed (biome {biome_id}): {ex}"); + } + } +} + +/// +/// Postfix: runs SPECIAL (rare per-chunk clustered spawns) and CUSTOM (modder callbacks) for the freshly +/// generated chunk, after vanilla has populated it so empty-tile checks see the real layout. +/// +[HarmonyPatch(typeof(ChunkGeneratorOverworld), nameof(ChunkGeneratorOverworld.GenerateUnexplored))] +internal static class GenerateUnexploredSpecialCustomPatch +{ + [HarmonyPostfix] + static void Postfix(ChunkData chunk_data, int X, int Z, int biome_id, bool is_quest_miniworld) + { + if (chunk_data == null) return; + var mgr = WorldGenManager.Instance; + if (!mgr.HasUnexploredWork(biome_id)) return; + + string zone; + try { zone = chunk_data.zone; } catch { zone = string.Empty; } + + mgr.RunUnexplored(chunk_data, X, Z, zone, biome_id, is_quest_miniworld); + } +} diff --git a/HAModHelper.Tests/EventBusTests.cs b/HAModHelper.Tests/EventBusTests.cs index c3d46d7..606d09e 100644 --- a/HAModHelper.Tests/EventBusTests.cs +++ b/HAModHelper.Tests/EventBusTests.cs @@ -1,5 +1,4 @@ -using System; -using HAModHelper.Events; +using HAModHelper.GamePlugin.Base.Events; using Xunit; namespace HAModHelper.Tests @@ -11,6 +10,7 @@ private class DummyEvent2 : BaseEvent { } private class DummyEvent3 : BaseEvent { } private class DummyEvent4 : BaseEvent { } private class DummyEvent5 : BaseEvent { } + private class DummyEvent7 : BaseEvent { } private class DummyEvent6 : BaseEvent { public string Wawa { get; set; } @@ -91,8 +91,14 @@ public void EventBusInstanceIdentical() { var bus = EventBus.Instance; var alsobus = EventBus.Instance; + + bus.Subscribe(_ => { }); Assert.Equal(bus, alsobus); + + var ev = new DummyEvent7(); + var result = bus.Fire(ev); + Assert.True(result.Fired); } [Fact] diff --git a/HAModHelper.Tests/HAModHelper.Tests.csproj b/HAModHelper.Tests/HAModHelper.Tests.csproj index 03921d1..1ebb033 100644 --- a/HAModHelper.Tests/HAModHelper.Tests.csproj +++ b/HAModHelper.Tests/HAModHelper.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable false @@ -17,5 +17,17 @@ + + $(MSBuildProjectDirectory)/../LemonLibs/Il2CppAssemblies/Il2Cppmscorlib.dll + True + + + $(MSBuildProjectDirectory)/../LemonLibs/net8/Il2CppInterop.Common.dll + True + + + $(MSBuildProjectDirectory)/../LemonLibs/net8/Il2CppInterop.Runtime.dll + True + \ No newline at end of file diff --git a/HAModHelper.Tests/ItemManagerTests.cs b/HAModHelper.Tests/ItemManagerTests.cs new file mode 100644 index 0000000..ca7330c --- /dev/null +++ b/HAModHelper.Tests/ItemManagerTests.cs @@ -0,0 +1,106 @@ +using HAModHelper.GamePlugin.Items.Interfaces; +using HAModHelper.GamePlugin.Items.Systems; +using Xunit; + +namespace HAModHelper.Tests +{ + // we reference internal helpers from the library via InternalsVisibleTo + public class ItemManagerTests + { + private class FakeResourceControl : IResourceControl + { + public Dictionary> loaded_inventory_item_files { get; set; } + = new Dictionary>(); + } + + public ItemManagerTests() + { + // clear singleton state so tests don't leak into one another + ItemManager.Instance.Reset(); + } + + [Fact] + public void AddAndRetrieveItem() + { + var im = ItemManager.Instance; + im.DebugResourceControlSource = new FakeResourceControl(); + + var item = new Item { ModId = "mod", ItemId = "foo", Name = "Test" }; + im.AddItem(item); + + var result = im.GetItem("mod:foo"); + Assert.Same(item, result); + } + + [Fact] + public void DeleteBaseItemBlocksAndRemoves() + { + var im = ItemManager.Instance; + im.DebugResourceControlSource = new FakeResourceControl(); + var item = new Item { ModId = "base", ItemId = "bar", Name = "Bar" }; + im.AddItem(item); + im.DeleteItem(item); + + Assert.Null(im.GetItem("base:bar")); + Assert.True(im.IsBaseItemBlocked("bar")); + } + + [Fact] + public void GetItemFallsBackToGameFields() + { + var im = ItemManager.Instance; + var fake = new FakeResourceControl(); + im.DebugResourceControlSource = fake; + + var fields = new Dictionary(); + fields["Name"] = "FromGame"; + fake.loaded_inventory_item_files["someid"] = fields; + + var item = im.GetItem("someid"); + Assert.NotNull(item); + Assert.Equal("someid", item.ItemId); + Assert.Equal("FromGame", item.Name); + } + + [Fact] + public void QueuedItemsAreProcessedWhenResourceControlBecomesAvailable() + { + var im = ItemManager.Instance; + im.DebugResourceControlSource = new DebugNoLoadResourceControl(); + var item = new Item { ModId = "mod", ItemId = "queued", Name = "Q" }; + im.AddItem(item); + + var fake = new FakeResourceControl(); + im.DebugResourceControlSource = fake; + im.ProcessQueuedItems(); + + Assert.True(fake.loaded_inventory_item_files.ContainsKey(item.Id)); + } + + [Fact] + public void ItemConverterRoundtripsCorrectly() + { + var original = new Item + { + ModId = "m", + ItemId = "i", + Name = "name", + Description = "desc", + StackLimit = 5, + SpritePath = "spr", + ExtraFields = { ["key"] = "val" } + }; + + var fields = ItemConverter.ToGameFields(original); + var round = ItemConverter.FromGameFields("m:i", fields); + + Assert.Equal(original.ModId, round.ModId); + Assert.Equal(original.ItemId, round.ItemId); + Assert.Equal(original.Name, round.Name); + Assert.Equal(original.Description, round.Description); + Assert.Equal(original.StackLimit, round.StackLimit); + Assert.Equal(original.SpritePath, round.SpritePath); + Assert.Equal("val", round.ExtraFields["key"]); + } + } +} diff --git a/HAModHelper.sln b/HAModHelper.sln index 20677f6..df3322b 100644 --- a/HAModHelper.sln +++ b/HAModHelper.sln @@ -7,6 +7,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HAModHelper.GamePlugin", "H EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HAModHelper.Tests", "HAModHelper.Tests\HAModHelper.Tests.csproj", "{D222712C-7A63-425B-A09A-756ED44AA49B}" EndProject +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/Packages/HybridAnimals.GameLibs.Android.2026.3.4.nupkg b/Packages/HybridAnimals.GameLibs.Android.2026.3.4.nupkg new file mode 100644 index 0000000..88cf7da Binary files /dev/null and b/Packages/HybridAnimals.GameLibs.Android.2026.3.4.nupkg differ diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..587e128 --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file