diff --git a/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs b/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs index c9252dd..731b79e 100644 --- a/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs +++ b/HAModHelper.GamePlugin/Base/Base.GamePlugin.cs @@ -94,6 +94,11 @@ public override void Load() stopwatch6.Stop(); Log.LogInfo($"[HAMH] Initialized DialogueManager in {stopwatch6.ElapsedMilliseconds}ms."); + var stopwatch7 = Stopwatch.StartNew(); + InteractableManager.Instance.Initialize(); + stopwatch7.Stop(); + Log.LogInfo($"[HAMH] Initialized InteractableManager in {stopwatch7.ElapsedMilliseconds}ms."); + //var stopwatch3 = Stopwatch.StartNew(); //UniverseLibConfig uvlconfig = new UniverseLibConfig //{ diff --git a/HAModHelper.GamePlugin/Items/INTERACTION_SYSTEM.md b/HAModHelper.GamePlugin/Items/INTERACTION_SYSTEM.md new file mode 100644 index 0000000..ee5cf74 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/INTERACTION_SYSTEM.md @@ -0,0 +1,89 @@ +# Hybrid Animals — object interaction system (reverse-engineered) + +How the game decides what interacting with a placed/spawned object does, and how the +`InteractableManager` API hooks into it. Source: il2cpp dump + Ghidra of `libil2cpp.so` +(game v2026.3.4). Implementation: `Items/Items.Interactables.cs`. + +## TL;DR + +There is **no** generic component/interface/flag system. Interaction behaviour is +**hardcoded in two layers, both keyed on the object's `item_name` string** — which is +the full mod id (e.g. `MyMod:MyChair`, the same id passed to `ItemManager.AddItem`), *not* +the display name: + +1. **Dispatch** — `GameController.player_interact(GameObject)` reads the target's + `Interactable.item_name` and runs a compiler-generated `switch(item_name)` + (string-hash jump table) to pick a behaviour. Unknown ids hit the default → nothing. +2. **Classification gates** — `InventoryUtils.IsChairObject(string)`, + `IsBedObject(string)`, etc. are hardcoded `string ==` chains. They gate the actual + behaviour (sit animation) *and* the placement logic. + +To make a modded object behave like a chair, **both** layers must recognise its id. + +## Layer 1 — the dispatch: `GameController.player_interact(GameObject interaction_target)` + +- Resolves the target's `Interactable` (bails on `Collectible`, follows `replace_for` + redirects, honours `is_house_exit`, lock checks, ...). +- `switch (ComputeStringHash(interactable.item_name))` — one case per recognised id: + - **Chair / Throne / Metal Chair / Sofa Chair / Park Bench / Stump Chair / Bed** → + local player `SharedCreature` (`GameController.player` 0x178 → `GetComponent`), + bail if `snapped_to_chair_obj` (0x6c), `TrySitInChairObj(interactable.active_obj_str)`, + show `GameplayGUIControl.Instance.end_sit_button`, `GameServerSender.SendSitInChair(id)`. + - **Crafting stations** → `inventory_ctr.GetCraftList("")` → + `OpenInventoryAndCrafting(...)`. The 2-arg overload titles the tab with + `TranslateItemName(item.item_name)` (→ the raw id for mod items); the 4-arg overload + `OpenInventoryAndCrafting(WindowControl.tab.right, list, non_inv_tab_name, "INVENTORY")` + lets us set the title ourselves. + - Navposts, companions, karaoke, vending, trophies, ... each their own case. +- Unrecognised id → default → nothing. **This is why a modded object is inert.** + +`Interactable` offsets (TypeDefIndex 1681): `corresponding_item` 0x18 · `item_name` 0x60 · +`active_obj_str` 0x68 (world-position key / seat id) · `replace_for` 0x50. + +## Layer 2 — the gates: `InventoryUtils.IsXObject(string item_name)` + +Static predicates, each a hardcoded equality chain: + +- `IsChairObject` → `"Chair" | "Throne" | "Metal Chair" | "Sofa Chair" | "Park Bench" | "Stump Chair"` +- `IsBedObject` → `"Bed"` +- siblings (all `static bool (string)`, patchable identically): `IsHouseObject`, `IsCaveObject`, + `IsHeavenDimension`/`IsHellDimension`/`IsPureDimension`, `IsPaintbrush`, `IsStamp`, ... + +`IsChairObject` has **6 real call sites**: `SharedCreature.TrySitInChairObj` (picks sit anim 4 +vs `IsBedObject` → sleep anim 5) and `ConstructionControl.{AllowedToPlace, +SnapMousePositionToObjectOrigins, AdjustBuildableInstance, PlayerRemoveAt, +TryMakeCompanionSitOnChair}`. Patching the predicate covers all six. + +> `Interactable.CustomStart` has the chair list **inlined** (not a call), but that path is a +> cosmetic furniture-pairing feature, not the sit interaction, so it needs no patching. + +`TrySitInChairObj` requires the placed prefab to have a **child transform named `"target"`** as +the seat anchor (position + rotation the player snaps to) — a **prefab requirement**, mirror the +vanilla `Chair` prefab. + +## The API (`InteractableManager`) + +`Items/Items.Interactables.cs`. `InteractableManager` is the single source of truth both layers +consult; the Harmony patches in that file are applied by the helper's `Harmony.PatchAll`: + +- `PlayerInteractInteractablePatch` (prefix on `player_interact`): registered id → run its + behaviour, skip the original; otherwise vanilla. A *parallel dispatch table* for modded ids — it + mirrors the game's switch, it does not spoof a vanilla id. +- `InteractableClassificationPatch` (one shared postfix over each `InventoryUtils.IsXObject` via + `TargetMethods()`): forces `true` for ids registered under that predicate's type. Never flips + `true`→`false`. `__originalMethod` selects which predicate ran. + +```csharp +InteractableManager.Instance.RegisterChair("MyMod:MyChair"); +InteractableManager.Instance.RegisterBed("MyMod:MyBed"); +// crafting station: its own type — craft list + optional tab title (defaults to the item's name) +InteractableManager.Instance.RegisterCraftingStation("MyMod:MyOven", "Crafting - Oven", "Oven"); +// anything else +InteractableManager.Instance.RegisterCustom("MyMod:MyThing", ctx => { /* ctx.Controller/Target/Interactable */ }); +``` + +### Adding a new built-in classification + +Add an `InteractableType` member and one row to `InteractableManager.ClassificationTable` +(`(Type, nameof(InventoryUtils.IsXObject))`). The shared postfix handles the rest. Behaviours +that need a `player_interact` action get a `case` in `InteractableRegistration.Execute`. diff --git a/HAModHelper.GamePlugin/Items/Items.Interactables.cs b/HAModHelper.GamePlugin/Items/Items.Interactables.cs new file mode 100644 index 0000000..4d158d6 --- /dev/null +++ b/HAModHelper.GamePlugin/Items/Items.Interactables.cs @@ -0,0 +1,575 @@ +using System.Reflection; +using HarmonyLib; +using HAModHelper.GamePlugin.Core; +using UnityEngine; + +namespace HAModHelper.GamePlugin.Items.Systems; + +// --------------------------------------------------------------------------- +// Hybrid Animals decides what interacting with a placed object does through TWO +// hardcoded, item_name-keyed layers (item_name == the full id, e.g. +// "MyMod:MyChair", which is what the game stores in Interactable.item_name): +// +// 1. Dispatch: GameController.player_interact(GameObject) reads the target's +// Interactable.item_name and runs a compiler-generated +// switch(item_name) (string-hash jump table) to choose a +// behaviour (sit, sleep, open a specific craft list, ...). +// Unknown ids hit the default and do nothing. +// 2. Gates: InventoryUtils.IsChairObject / IsBedObject / ... are hardcoded +// string-equality chains. Six call sites gate chair behaviour on +// IsChairObject: the sit animation (SharedCreature.TrySitInChairObj) +// and five ConstructionControl placement/snap/companion/removal sites. +// +// Neither layer is component- or interface-driven, so a modded object's id +// matches nothing and the object is inert. This manager is the single source of +// truth both layers consult at runtime: the player_interact prefix dispatches +// registered ids to their behaviour, and one shared postfix on each +// InventoryUtils.IsXObject predicate answers true for registered ids. +// --------------------------------------------------------------------------- + +/// The kind of interactable an item id is registered as. +public enum InteractableType +{ + /// A seat. Classified by IsChairObject; interacting makes the player sit. + Chair, + + /// A bed. Classified by IsBedObject; interacting makes the player lie down. + Bed, + + /// + /// A crafting station. Interacting opens the crafting menu for + /// , titled + /// . + /// + OpenCrafting, + + /// + /// A storage container (chest). Interacting opens the world-container UI for the object, exactly + /// like a vanilla chest. The tab shows instead + /// of the raw id, and the container has pages of slots. + /// + Container, + + /// + /// No built-in behaviour or classification — + /// runs on interact. Use for anything not covered above (custom menus, scripted effects, ...). + /// + Custom, +} + +/// Context handed to an interaction behaviour when a registered object is used. +public readonly struct InteractContext(GameController controller, GameObject target, Interactable interactable) +{ + /// The GameController whose player_interact was invoked. + public GameController Controller { get; } = controller; + + /// The interacted world object (the argument to player_interact). + public GameObject Target { get; } = target; + + /// The object's Interactable component (carries item_name, active_obj_str, ...). + public Interactable Interactable { get; } = interactable; +} + +/// One registered interactable: an item id plus how it should behave. +public sealed class InteractableRegistration +{ + /// + /// The id the game stores in Interactable.item_name — the full mod id + /// (e.g. "MyMod:MyChair"), the same id passed to . + /// + public required string ItemName { get; init; } + + /// Which kind of interactable this id is. + public required InteractableType Type { get; init; } + + /// + /// Behaviour invoked on interact. Required for ; + /// ignored for the other kinds, which run their own behaviour. + /// + public Action? OnInteract { get; init; } + + /// + /// For : the craft-list file name to open, + /// e.g. "Crafting - Oven". Required for that type, ignored otherwise. + /// + public string? CraftListName { get; init; } + + /// + /// For : the title shown on the crafting tab. + /// When null, the item's display name (or, failing that, its id) is used — instead of the + /// raw id the game would otherwise show. + /// + public string? CraftMenuTitle { get; init; } + + /// + /// For : the title shown on the container tab. When null, + /// the item's display name (or, failing that, its id) is used — instead of the raw id + /// (e.g. "MyMod:MyChest") the game would otherwise show. + /// + public string? DisplayName { get; init; } + + /// + /// For : how many pages of slots the container has. + /// The game supports 1–3 pages (each page is inventory_ctr.n_slots_per_page_ slots); + /// values are clamped into that range. Defaults to 1. + /// + public int Pages { get; init; } = 1; + + /// Run this registration's behaviour. + internal void Execute(InteractContext ctx) + { + switch (Type) + { + case InteractableType.Chair: + case InteractableType.Bed: + Sit(ctx); + break; + case InteractableType.OpenCrafting: + OpenCrafting(); + break; + case InteractableType.Container: + OpenContainer(ctx); + break; + case InteractableType.Custom: + OnInteract?.Invoke(ctx); + break; + } + } + + // Container case of player_interact. Reverse-engineered from GameController.player_interact: it + // records the interacted object via GameController.NoteInteractingElement (chunk/inner grid coords + // + the object's InventoryItem + rot, all carried on the Interactable), then calls + // inventory_ctr.TryOpenWorldContainer with the same values. TryOpenWorldContainer handles both + // the online path (server request for the container contents) and the offline path + // (LoadFromDiskAsContainer -> SucceedOpenWorldContainer -> OpenInventoryAndContainer, which shows + // the UI). It keys the container off GameController.interacting_element_item, so we set that here + // rather than relying on it already being current. + private static void OpenContainer(InteractContext ctx) + { + var inv = inventory_ctr.Instance; + if (inv == null) return; + + var it = ctx.Interactable; + var item = it.corresponding_item; // the placed object's InventoryItem (the container) + if (item == null) return; + + ctx.Controller.NoteInteractingElement( + it.origin_chunkX, it.origin_chunkZ, + it.origin_innerX, it.origin_innerZ, + item, it.temp_rot); + + inv.TryOpenWorldContainer( + item, it.temp_rot, + it.origin_innerX, it.origin_innerZ, + it.origin_chunkX, it.origin_chunkZ); + } + + // Chair/bed case of player_interact. TrySitInChairObj itself calls IsChairObject/IsBedObject + // (which our classification postfix has made answer true for this id) to pick sit vs sleep. + // Requires the placed prefab to expose a child transform named "target" as the seat anchor. + private static void Sit(InteractContext ctx) + { + var player = ctx.Controller.player; + if (player == null) return; + + var creature = player.GetComponent(); + if (creature == null) return; + if (creature.snapped_to_chair_obj) return; // already seated -> vanilla no-ops too + + var seatId = ctx.Interactable.active_obj_str; // world-position key of the seat object + creature.TrySitInChairObj(seatId); + + var gui = GameplayGUIControl.Instance; // show the "stand up" button + if (gui != null && gui.end_sit_button != null) + gui.end_sit_button.SetActive(true); + + var sender = GameServerSender.Instance; // sync to other players + if (sender != null) + sender.SendSitInChair(seatId); + } + + // Workbench case of player_interact. Uses the 4-arg overload so the tab title is ours rather + // than the raw id the 2-arg overload derives from the item name. + private void OpenCrafting() + { + var inv = inventory_ctr.Instance; + if (inv == null) return; + + var list = inv.GetCraftList(CraftListName!); + var title = CraftMenuTitle ?? ItemManager.Instance.GetItem(ItemName)?.Name ?? ItemName; + inv.OpenInventoryAndCrafting(WindowControl.tab.right, list, title, "INVENTORY"); + } +} + +/// +/// Registers placed/spawned objects as recognised interactables (chairs, beds, crafting stations, +/// or fully custom behaviours). Populate it with the Register* 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. +/// +public sealed class InteractableManager +{ + public static InteractableManager Instance { get; } = new(); + private InteractableManager() { } + + // item_name -> registration, consulted by the player_interact dispatch prefix. + private readonly Dictionary _byItemName = new(StringComparer.Ordinal); + + // InteractableType -> ids registered as that type, consulted by the IsXObject postfixes. + private readonly Dictionary> _byType = new(); + + // Built-in classifications: type -> the InventoryUtils predicate that must answer true for it. + // Add a new classification by adding one row here (and an InteractableType member). + private static readonly (InteractableType Type, string PredicateMethod)[] ClassificationTable = + [ + (InteractableType.Chair, nameof(InventoryUtils.IsChairObject)), + (InteractableType.Bed, nameof(InventoryUtils.IsBedObject)), + ]; + + public void Initialize() { } + + // TEST-ONLY: reset system state. + public void Reset() + { + _byItemName.Clear(); + _byType.Clear(); + } + + /// Register (or replace) an interactable. Safe to call at any time. + public void Register(InteractableRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + if (string.IsNullOrEmpty(registration.ItemName)) + throw new ArgumentException("ItemName is required.", nameof(registration)); + if (registration.Type == InteractableType.Custom && registration.OnInteract is null) + throw new ArgumentException("Custom interactables require an OnInteract handler.", nameof(registration)); + if (registration.Type == InteractableType.OpenCrafting && string.IsNullOrEmpty(registration.CraftListName)) + throw new ArgumentException("OpenCrafting interactables require a CraftListName.", nameof(registration)); + + _byItemName[registration.ItemName] = registration; + + if (!_byType.TryGetValue(registration.Type, out var set)) + _byType[registration.Type] = set = new HashSet(StringComparer.Ordinal); + set.Add(registration.ItemName); + } + + /// Register a seat. Interacting makes the player sit (and placement treats it as a chair). + public void RegisterChair(string itemName) + => Register(new InteractableRegistration { ItemName = itemName, Type = InteractableType.Chair }); + + /// Register a bed. Interacting makes the player lie down (and placement treats it as a bed). + public void RegisterBed(string itemName) + => Register(new InteractableRegistration { ItemName = itemName, Type = InteractableType.Bed }); + + /// + /// Register a crafting station. Interacting opens the crafting menu for + /// (e.g. "Crafting - Oven"), titled (defaults to the item's + /// display name). + /// + public void RegisterCraftingStation(string itemName, string craftListName, string? menuTitle = null) + => Register(new InteractableRegistration + { + ItemName = itemName, + Type = InteractableType.OpenCrafting, + CraftListName = craftListName, + CraftMenuTitle = menuTitle, + }); + + /// + /// Register a storage container (chest). Interacting opens the world-container UI for the placed + /// object, exactly like a vanilla chest (works online and offline). + /// + /// The full mod id stored in Interactable.item_name (e.g. "MyMod:MyChest"). + /// + /// Title shown on the container tab. When null, the item's display name (or its id) is used, + /// instead of the raw id the game would otherwise show. + /// + /// How many pages of slots the container has (1–3, clamped). Defaults to 1. + public void RegisterContainer(string itemName, string? displayName = null, int pages = 1) + => Register(new InteractableRegistration + { + ItemName = itemName, + Type = InteractableType.Container, + DisplayName = displayName, + Pages = pages, + }); + + /// Register an interactable with fully custom behaviour run on interact. + public void RegisterCustom(string itemName, Action onInteract) + => Register(new InteractableRegistration { ItemName = itemName, Type = InteractableType.Custom, OnInteract = onInteract }); + + // ── consulted by the patches ──────────────────────────────────────────────── + + internal InteractableRegistration? Lookup(string itemName) + => _byItemName.TryGetValue(itemName, out var reg) ? reg : null; + + /// The registration for if it is a registered container, else null. + internal InteractableRegistration? LookupContainer(string? itemName) + => itemName is not null && _byItemName.TryGetValue(itemName, out var reg) && reg.Type == InteractableType.Container + ? reg + : null; + + /// + /// The registration for the container the player is currently interacting with (keyed off + /// GameController.interacting_element_item), if that object is a registered container. + /// Used by the container UI patches, which only see the inventory controller, not the object. + /// + internal InteractableRegistration? CurrentContainerRegistration() + { + var item = GameController.Instance?.interacting_element_item; + return item == null ? null : LookupContainer(item.item_name); + } + + internal bool IsRegisteredClassification(string predicateMethod, string? itemName) + { + if (itemName is null) return false; + foreach (var (type, predicate) in ClassificationTable) + if (predicate == predicateMethod) + return _byType.TryGetValue(type, out var set) && set.Contains(itemName); + return false; + } + + /// The InventoryUtils predicate methods the classification postfix is applied to. + internal static IEnumerable ClassificationTargetMethods() + { + foreach (var (_, predicate) in ClassificationTable) + { + var method = AccessTools.Method(typeof(InventoryUtils), predicate, [typeof(string)]); + if (method is null) + throw new MissingMethodException($"InventoryUtils.{predicate}(string) not found in the game assembly."); + yield return method; + } + } +} + +// ── Harmony patches (applied by the helper's Harmony.PatchAll) ────────────────── + +/// +/// Prefix on the interact dispatch. The vanilla method switches on Interactable.item_name and +/// does nothing for ids it doesn't recognise; for a registered id we run the behaviour and skip the +/// original, otherwise we defer to vanilla. +/// +[HarmonyPatch(typeof(GameController), nameof(GameController.player_interact))] +internal static class PlayerInteractInteractablePatch +{ + [HarmonyPrefix] + static bool Prefix(GameController __instance, GameObject interaction_target) + { + if (interaction_target == null) return true; + + Interactable interactable; + InteractableRegistration? reg; + try + { + interactable = interaction_target.GetComponent(); + if (interactable == null) return true; // not an interactable -> vanilla + reg = InteractableManager.Instance.Lookup(interactable.item_name); + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Interactable identify failed: {ex}"); + return true; + } + + if (reg is null) return true; // not one of ours -> vanilla + + try + { + reg.Execute(new InteractContext(__instance, interaction_target, interactable)); + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Interactable handler for '{interactable.item_name}' threw: {ex}"); + } + return false; // we own this interaction + } +} + +/// +/// Shared postfix on the InventoryUtils.IsXObject(string) classification predicates. Forces the +/// result true for ids registered under that predicate's type; never flips true to false, so vanilla +/// items are unaffected. __originalMethod identifies which predicate ran. +/// +[HarmonyPatch] +internal static class InteractableClassificationPatch +{ + static IEnumerable TargetMethods() => InteractableManager.ClassificationTargetMethods(); + + [HarmonyPostfix] + static void Postfix(string __0, ref bool __result, MethodBase __originalMethod) + { + if (__result) return; + try + { + if (InteractableManager.Instance.IsRegisteredClassification(__originalMethod.Name, __0)) + __result = true; + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Interactable classification postfix failed: {ex}"); + } + } +} + +// ── Container UI patches ──────────────────────────────────────────────────────── +// +// A modded chest opens through the vanilla container flow (see OpenContainer above), but two pieces +// of that flow are hardcoded against the game's own chest ids and so don't know about ours: +// +// • The tab title. SucceedOpenWorldContainer derives it from TranslateItemName(item_name); for a +// modded id that resolves to the raw id (e.g. "MyMod:MyChest"). We rewrite the title that +// inventory_ctr.OpenInventoryAndContainer is about to display. +// • The page count. goto_crafting_tab switches on item_name to pick how many pages to lay out and +// hits a 1-page default for unknown ids. The count is funnelled into inventory_ctr.LayOutInvSlots' +// n_pages argument (which alone drives the page-switcher buttons; navigation/redraw are generic), +// and drag/drop validity per page comes from inventory_ctr.GetPermittedContainerSlots. We override +// both for our containers. + +/// +/// Prefix on the world-container UI open. Replaces the tab title with the registered container's +/// (falling back to the item's display name, then +/// its id) instead of the raw id the game derives from the item name. +/// +[HarmonyPatch(typeof(inventory_ctr), nameof(inventory_ctr.OpenInventoryAndContainer))] +internal static class ContainerTabTitlePatch +{ + [HarmonyPrefix] + static void Prefix(ref string non_inv_tab_name) + { + try + { + var reg = InteractableManager.Instance.CurrentContainerRegistration(); + if (reg is null) return; + non_inv_tab_name = reg.DisplayName + ?? ItemManager.Instance.GetItem(reg.ItemName)?.Name + ?? reg.ItemName; + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Container tab-title prefix failed: {ex}"); + } + } +} + +/// +/// Prefix on the inventory/container slot layout. When the container tab is being laid out for one of +/// our registered containers, overrides n_pages with the registered page count (1–3) so the +/// page-switcher buttons appear; the game's own navigation and redraw handle the rest generically. +/// +[HarmonyPatch(typeof(inventory_ctr), nameof(inventory_ctr.LayOutInvSlots))] +internal static class ContainerPageCountPatch +{ + [HarmonyPrefix] + static void Prefix(ref int n_pages) + { + try + { + // Only touch the container tab (the same gate inv_page_switch uses), never the inventory + // tab or other miniwindows that also call LayOutInvSlots. + var wc = WindowControl.Instance; + if (wc == null + || wc.curr_miniwindow != WindowControl.miniwindow_type_t.inventory_and_container + || wc.curr_miniwindow_tab_selected != WindowControl.tab.right) + return; + + var reg = InteractableManager.Instance.CurrentContainerRegistration(); + if (reg is null) return; + + n_pages = Math.Clamp(reg.Pages, 1, 3); + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Container page-count prefix failed: {ex}"); + } + } +} + +/// +/// Postfix on the per-page permitted-slot lookup (drives which slots accept drag/drop). For a +/// registered container it returns the slot indices for the requested page, mirroring the layout the +/// game uses for its own multi-page chests (page 1 → [0, n), page 2 → [p2_begin, p2_begin+n), +/// page 3 → [p2_begin+n, p2_begin+2n), where n = n_slots_per_page_). Pages beyond the registered +/// count get no slots. +/// +[HarmonyPatch(typeof(inventory_ctr), nameof(inventory_ctr.GetPermittedContainerSlots))] +internal static class ContainerPermittedSlotsPatch +{ + [HarmonyPostfix] + static void Postfix(inventory_ctr.container_style_t style, string item_name, inventory_ctr.ptype page, + ref Il2CppSystem.Collections.Generic.List __result) + { + try + { + if (style != inventory_ctr.container_style_t.world_container) return; + var reg = InteractableManager.Instance.LookupContainer(item_name); + if (reg is null) return; + + int pages = Math.Clamp(reg.Pages, 1, 3); + int nspp = inventory_ctr.n_slots_per_page_; + int p2 = inventory_ctr.p2_begin_; + + var list = new Il2CppSystem.Collections.Generic.List(); + void AddPage(int pageIndex) + { + int start = pageIndex == 0 ? 0 : p2 + (pageIndex - 1) * nspp; + for (int i = 0; i < nspp; i++) list.Add(start + i); + } + + switch (page) + { + case inventory_ctr.ptype.firstPage: AddPage(0); break; + case inventory_ctr.ptype.secondPage: if (pages >= 2) AddPage(1); break; + case inventory_ctr.ptype.thirdPage: if (pages >= 3) AddPage(2); break; + case inventory_ctr.ptype.eitherPage: + for (int p = 0; p < pages; p++) AddPage(p); + break; + } + + __result = list; + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Container permitted-slots postfix failed: {ex}"); + } + } +} + +/// +/// Postfix on InventoryUtils.UsesBasketId(InventoryItem) — the predicate that decides whether an +/// object stores its contents under a per-instance basket_id. +/// +/// +/// This is what actually keys a chest's contents to the specific placed object. At put-down time +/// inventory_ctr.FinalizeItemBeforePutDown assigns a fresh unique basket_id +/// (ConstructionControl.GetNewUniqueId) — but only when this predicate is true and the item +/// doesn't already have one — and stores it in the object's saved data; opening then loads/saves the +/// container file for that id. The predicate is a hardcoded item_name list, so a modded container never +/// qualifies: it keeps basket_id == 0 and every modded chest in the world shares that one +/// container. Forcing true for registered containers gives each placed instance its own id. +/// +/// +/// +/// Because the id lives in the object's world data, relocating the chest with the build/move tools +/// keeps the same object and so keeps its contents; only taking it back into the inventory (which +/// resets basket_id via DropExtraItemDataOnPickup) and placing a fresh one starts an empty +/// container — i.e. exactly how vanilla chests behave. +/// +/// +[HarmonyPatch(typeof(InventoryUtils), nameof(InventoryUtils.UsesBasketId), new Type[] { typeof(InventoryItem) })] +internal static class ContainerUsesBasketIdPatch +{ + [HarmonyPostfix] + static void Postfix(InventoryItem item, ref bool __result) + { + if (__result) return; // already a basket-id object -> leave it + try + { + if (item != null && InteractableManager.Instance.LookupContainer(item.item_name) != null) + __result = true; + } + catch (Exception ex) + { + HAMHMod.Logger?.LogError($"[HAMH] Container UsesBasketId postfix failed: {ex}"); + } + } +}