diff --git a/.claude/settings.json b/.claude/settings.json
index 9f61922..6f834ea 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,10 +1,10 @@
{
- "$schema": "https://claude.ai/code/settings-schema.json",
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
- "Bash(dotnet build*)",
- "Bash(dotnet clean*)",
- "Bash(dotnet restore*)",
+ "Bash(dotnet build:*)",
+ "Bash(dotnet clean:*)",
+ "Bash(dotnet restore:*)",
"WebFetch(domain:code.claude.com)"
]
}
diff --git a/CLAUDE.md b/CLAUDE.md
index e0b679f..38ee892 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,6 +83,43 @@ The game loads mods from `Duckov_Data/Mods/`. Each mod requires:
- **`StorageHelper`** (`src/StorageHelper.cs`): Helper class for creating storage items with multi-tag slot filtering, enabling hierarchical storage systems.
+- **`DisassemblyHelper`** (`src/DisassemblyHelper.cs`): Helper class for adding disassembly (deconstruction) formulas to custom items at runtime.
+
+### Disassembly System Pattern
+
+The game's disassembly system uses `DecomposeDatabase` (singleton in `Duckov.Economy` namespace) to store formulas. Key implementation details:
+
+**Types:**
+- `DecomposeFormula`: Contains `item` (TypeID), `valid` (bool), and `result` (Cost)
+- `Cost`: A **struct** with `money` (long) and `items` (ItemEntry[])
+- `Cost+ItemEntry`: **Nested struct** inside Cost with `id` (int) and `amount` (long)
+
+**Critical Reflection Pattern (Cost is a struct):**
+```csharp
+// Cost is a VALUE TYPE - must box before reflection, then unbox
+Cost result = new Cost { money = 0L };
+object boxedResult = result; // Box the struct
+itemsField.SetValue(boxedResult, itemEntries); // Modify boxed copy
+result = (Cost)boxedResult; // Unbox back
+formula.result = result;
+```
+
+**Adding a Formula:**
+1. Get `DecomposeDatabase.Instance`
+2. Access private `entries` field via reflection
+3. Create `DecomposeFormula` with item TypeID and `valid = true`
+4. Create `Cost` with money and/or items array
+5. For items: Create `Cost+ItemEntry[]` via reflection (type found from `Cost.items` field type)
+6. Add formula to list, write back via reflection
+7. Call private `RebuildDictionary()` method
+
+**Known Material TypeIDs:**
+- Polyethylene Sheet: `764`
+
+**Cleanup:** Track added item IDs and remove formulas in `OnDestroy()`.
+
+See `DisassemblyHelper.cs` for complete implementation.
+
### Dependencies
- **HarmonyLoadMod** (Workshop ID: 3589088839): Required mod dependency providing Harmony 2.4.1. Referenced at build time but not bundled to avoid version conflicts.
@@ -153,6 +190,10 @@ Key namespaces and APIs from the game:
- Spawn storage items (Card Binder, Binder Sheet)
- Spawn random cards by rarity
- Draggable OnGUI window interface
+- Disassembly support for storage items:
+ - Binder Sheet → 2x Polyethylene Sheet
+ - Card Binder → 4x Polyethylene Sheet
+ - Cards/packs intentionally have no disassembly (collectibles)
- Deploy/remove scripts for quick iteration
- Unit tests for parsing logic and pack system
diff --git a/src/DisassemblyHelper.cs b/src/DisassemblyHelper.cs
new file mode 100644
index 0000000..954cb5a
--- /dev/null
+++ b/src/DisassemblyHelper.cs
@@ -0,0 +1,293 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using UnityEngine;
+using Duckov.Economy;
+
+namespace TradingCardMod
+{
+ ///
+ /// Helper class for adding disassembly formulas to custom items.
+ /// Uses reflection to modify the game's DecomposeDatabase at runtime.
+ /// Based on the approach from AdditionalCollectibles mod.
+ ///
+ public static class DisassemblyHelper
+ {
+ // Common material IDs from the game
+ public static readonly int Metal01 = 91043;
+ public static readonly int Metal02 = 91044;
+ public static readonly int Metal03 = 91045;
+ public static readonly int Plastic01 = 91046;
+ public static readonly int Plastic02 = 91047;
+ public static readonly int Plastic03 = 91048;
+
+ // Track added formulas for cleanup
+ private static readonly List _addedItemIds = new List();
+
+ // Cached reflection info for ItemEntry type
+ private static Type? _itemEntryType;
+ private static FieldInfo? _itemEntryIdField;
+ private static FieldInfo? _itemEntryAmountField;
+
+ ///
+ /// Initializes the ItemEntry type info via reflection.
+ /// Gets the type from Cost.items field to find the correct ItemEntry type.
+ ///
+ private static bool InitializeItemEntryType()
+ {
+ if (_itemEntryType != null)
+ return true;
+
+ try
+ {
+ // Get ItemEntry type from Cost.items field (more reliable than searching by name)
+ FieldInfo? itemsField = typeof(Cost).GetField("items", BindingFlags.Instance | BindingFlags.Public);
+ if (itemsField == null)
+ {
+ Debug.LogError("[TradingCardMod] Could not find Cost.items field!");
+ return false;
+ }
+
+ // items is ItemEntry[], so get the element type
+ Type itemsType = itemsField.FieldType;
+ if (!itemsType.IsArray)
+ {
+ Debug.LogError("[TradingCardMod] Cost.items is not an array!");
+ return false;
+ }
+
+ _itemEntryType = itemsType.GetElementType();
+ if (_itemEntryType == null)
+ {
+ Debug.LogError("[TradingCardMod] Could not get ItemEntry element type!");
+ return false;
+ }
+
+ Debug.Log($"[TradingCardMod] Found ItemEntry type: {_itemEntryType.FullName}");
+
+ _itemEntryIdField = _itemEntryType.GetField("id", BindingFlags.Instance | BindingFlags.Public);
+ _itemEntryAmountField = _itemEntryType.GetField("amount", BindingFlags.Instance | BindingFlags.Public);
+
+ if (_itemEntryIdField == null || _itemEntryAmountField == null)
+ {
+ // Try non-public fields
+ _itemEntryIdField = _itemEntryType.GetField("id", BindingFlags.Instance | BindingFlags.NonPublic);
+ _itemEntryAmountField = _itemEntryType.GetField("amount", BindingFlags.Instance | BindingFlags.NonPublic);
+ }
+
+ if (_itemEntryIdField == null || _itemEntryAmountField == null)
+ {
+ // Log available fields for debugging
+ var fields = _itemEntryType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ Debug.LogError($"[TradingCardMod] Could not find ItemEntry id/amount fields! Available: {string.Join(", ", fields.Select(f => f.Name))}");
+ return false;
+ }
+
+ Debug.Log($"[TradingCardMod] ItemEntry fields: id={_itemEntryIdField.Name}, amount={_itemEntryAmountField.Name}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[TradingCardMod] Error initializing ItemEntry type: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Creates an ItemEntry array using reflection.
+ ///
+ private static Array? CreateItemEntryArray((int id, long amount)[] items)
+ {
+ if (!InitializeItemEntryType() || _itemEntryType == null)
+ return null;
+
+ Array array = Array.CreateInstance(_itemEntryType, items.Length);
+ for (int i = 0; i < items.Length; i++)
+ {
+ object entry = Activator.CreateInstance(_itemEntryType)!;
+ _itemEntryIdField!.SetValue(entry, items[i].id);
+ _itemEntryAmountField!.SetValue(entry, items[i].amount);
+ array.SetValue(entry, i);
+ }
+ return array;
+ }
+
+ ///
+ /// Adds a disassembly formula for an item.
+ ///
+ /// The TypeID of the item that can be disassembled
+ /// Money reward when disassembled (0 for none)
+ /// Array of (itemId, amount) tuples for materials received
+ /// True if formula was added successfully
+ public static bool AddFormula(int itemId, long money, params (int id, long amount)[] resultItems)
+ {
+ try
+ {
+ DecomposeDatabase instance = DecomposeDatabase.Instance;
+ if (instance == null)
+ {
+ Debug.LogError("[TradingCardMod] DecomposeDatabase.Instance is null!");
+ return false;
+ }
+
+ // Get the private entries field via reflection
+ FieldInfo? entriesField = typeof(DecomposeDatabase).GetField("entries",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ if (entriesField == null)
+ {
+ Debug.LogError("[TradingCardMod] Could not find 'entries' field on DecomposeDatabase!");
+ return false;
+ }
+
+ DecomposeFormula[] currentEntries = (DecomposeFormula[])entriesField.GetValue(instance);
+ List formulaList = new List(currentEntries);
+
+ // Check if formula already exists for this item
+ foreach (DecomposeFormula existing in formulaList)
+ {
+ if (existing.item == itemId)
+ {
+ Debug.LogWarning($"[TradingCardMod] Disassembly formula already exists for item {itemId}, skipping");
+ return false;
+ }
+ }
+
+ // Create new formula
+ DecomposeFormula newFormula = new DecomposeFormula
+ {
+ item = itemId,
+ valid = true
+ };
+
+ // Create cost (result items)
+ // Cost is a struct, so we need to box it for reflection to work
+ Cost result = new Cost
+ {
+ money = money
+ };
+
+ // Build item entries array using reflection
+ if (resultItems.Length > 0)
+ {
+ Array? itemEntries = CreateItemEntryArray(resultItems);
+ if (itemEntries == null)
+ {
+ Debug.LogError("[TradingCardMod] Failed to create ItemEntry array!");
+ return false;
+ }
+
+ // Set items field on Cost using reflection (it expects Cost+ItemEntry[])
+ // Must box the struct first, modify, then unbox
+ FieldInfo? itemsField = typeof(Cost).GetField("items", BindingFlags.Instance | BindingFlags.Public);
+ if (itemsField != null)
+ {
+ object boxedResult = result; // Box the struct
+ itemsField.SetValue(boxedResult, itemEntries);
+ result = (Cost)boxedResult; // Unbox back
+ Debug.Log($"[TradingCardMod] Set {itemEntries.Length} items on Cost for item {itemId}");
+ }
+ }
+
+ newFormula.result = result;
+
+ // Add to list and write back
+ formulaList.Add(newFormula);
+ entriesField.SetValue(instance, formulaList.ToArray());
+
+ // Track for cleanup
+ if (!_addedItemIds.Contains(itemId))
+ {
+ _addedItemIds.Add(itemId);
+ }
+
+ // Rebuild internal dictionary for lookup performance
+ MethodInfo? rebuildMethod = typeof(DecomposeDatabase).GetMethod("RebuildDictionary",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ rebuildMethod?.Invoke(instance, null);
+
+ Debug.Log($"[TradingCardMod] Added disassembly formula for item {itemId}");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[TradingCardMod] Error adding disassembly formula: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Adds a simple money-only disassembly formula.
+ ///
+ /// The TypeID of the item
+ /// Money reward when disassembled
+ /// True if formula was added successfully
+ public static bool AddMoneyOnlyFormula(int itemId, long money)
+ {
+ return AddFormula(itemId, money);
+ }
+
+ ///
+ /// Removes all disassembly formulas added by this mod.
+ /// Should be called during mod cleanup/unload.
+ ///
+ public static void Cleanup()
+ {
+ try
+ {
+ DecomposeDatabase instance = DecomposeDatabase.Instance;
+ if (instance == null)
+ {
+ Debug.LogWarning("[TradingCardMod] DecomposeDatabase.Instance is null during cleanup");
+ _addedItemIds.Clear();
+ return;
+ }
+
+ FieldInfo entriesField = typeof(DecomposeDatabase).GetField("entries",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ if (entriesField == null)
+ {
+ Debug.LogWarning("[TradingCardMod] Could not find 'entries' field during cleanup");
+ _addedItemIds.Clear();
+ return;
+ }
+
+ DecomposeFormula[] currentEntries = (DecomposeFormula[])entriesField.GetValue(instance);
+ List formulaList = new List(currentEntries);
+
+ int removedCount = 0;
+ for (int i = formulaList.Count - 1; i >= 0; i--)
+ {
+ if (_addedItemIds.Contains(formulaList[i].item))
+ {
+ formulaList.RemoveAt(i);
+ removedCount++;
+ }
+ }
+
+ entriesField.SetValue(instance, formulaList.ToArray());
+
+ // Rebuild dictionary
+ MethodInfo rebuildMethod = typeof(DecomposeDatabase).GetMethod("RebuildDictionary",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ rebuildMethod?.Invoke(instance, null);
+
+ Debug.Log($"[TradingCardMod] Removed {removedCount} disassembly formulas");
+ _addedItemIds.Clear();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[TradingCardMod] Error during disassembly cleanup: {ex.Message}");
+ _addedItemIds.Clear();
+ }
+ }
+
+ ///
+ /// Gets the count of disassembly formulas added by this mod.
+ ///
+ public static int GetAddedFormulaCount()
+ {
+ return _addedItemIds.Count;
+ }
+ }
+}
diff --git a/src/ModBehaviour.cs b/src/ModBehaviour.cs
index 5d1a079..4e1b0ac 100644
--- a/src/ModBehaviour.cs
+++ b/src/ModBehaviour.cs
@@ -126,6 +126,9 @@ namespace TradingCardMod
// Create card packs
CreateCardPacks();
+ // Set up disassembly formulas for all items
+ SetupDisassembly();
+
Debug.Log("[TradingCardMod] Mod initialized successfully!");
}
catch (Exception ex)
@@ -448,6 +451,40 @@ namespace TradingCardMod
Debug.Log($"[TradingCardMod] Created {_registeredPacks.Count} card packs");
}
+ ///
+ /// Sets up disassembly formulas for storage items only.
+ /// Cards intentionally have no disassembly (they're collectibles).
+ ///
+ private void SetupDisassembly()
+ {
+ int formulasAdded = 0;
+
+ // Polyethylene Sheet TypeID (base game material)
+ const int POLYETHYLENE_SHEET_ID = 764;
+
+ // Binder Sheet: 2x polyethylene sheet
+ if (_binderItem != null)
+ {
+ if (DisassemblyHelper.AddFormula(BINDER_SHEET_ITEM_ID, 0L, (POLYETHYLENE_SHEET_ID, 2L)))
+ {
+ formulasAdded++;
+ }
+ }
+
+ // Card Binder: 4x polyethylene sheet
+ if (_cardBoxItem != null)
+ {
+ if (DisassemblyHelper.AddFormula(CARD_BINDER_ITEM_ID, 0L, (POLYETHYLENE_SHEET_ID, 4L)))
+ {
+ formulasAdded++;
+ }
+ }
+
+ // No disassembly for cards or packs (collectibles)
+
+ Debug.Log($"[TradingCardMod] Added {formulasAdded} disassembly formulas");
+ }
+
///
/// Update is called every frame. Used for debug input handling.
///
@@ -919,6 +956,9 @@ namespace TradingCardMod
// Clean up packs
PackHelper.Cleanup();
+ // Clean up disassembly formulas
+ DisassemblyHelper.Cleanup();
+
// Clean up tags
TagHelper.Cleanup();