WhipStacking Walkthrough¶
Difficulty: Intermediate Concepts: Harmony prefixes, restoring removed game mechanics, multi-entry state tracking, private-field reflection
WhipStacking restores pre-1.4.5 whip tag stacking behavior, allowing multiple whip tags to be active on the same NPC simultaneously.
What It Does¶
In Terraria 1.4.5, only one whip tag can be active on an NPC at a time. This mod restores the ability for multiple whip tags to stack, enabling hybrid whip builds where tags from different whips all apply their effects.
Key Concepts¶
1. Replacing Vanilla State with Multi-Entry Tracking¶
The core technique is replacing Terraria's single-effect whip model with a per-player dictionary:
// Vanilla: TagEffectState._effect = single UniqueTagEffect
// Mod: Dictionary<whipType, WhipTagEntry> per player
public class WhipTagEntry
{
public int WhipType; // Item ID of this whip
public UniqueTagEffect Effect; // Direct reference — mods reference Terraria.exe
public int[] TimeLeftOnNPC; // Timer per NPC (max 200)
public int[] ProcTimeLeftOnNPC; // Proc window per NPC
public bool HasAnyActiveTags()
{
for (int i = 0; i < TimeLeftOnNPC.Length; i++)
if (TimeLeftOnNPC[i] > 0) return true;
return false;
}
}
Each player has their own dictionary tracking all active whips. Each whip maintains separate NPC timer arrays.
2. Harmony Prefix Pattern (Replace Vanilla)¶
All 10 patches use the same pattern: prefix that returns false to skip vanilla when enabled, true to run vanilla when disabled:
[HarmonyPatch]
public static class TagPatches
{
public static bool TrySetActiveEffect_Prefix(TagEffectState __instance, int type)
{
if (!Enabled) return true; // Run vanilla when disabled
try
{
SetActiveEffect(__instance, type);
}
catch (Exception ex)
{
_log?.Error($"TrySetActiveEffect error: {ex.Message}");
return true; // Fall back to vanilla on error
}
return false; // Skip vanilla
}
}
The try-catch with vanilla fallback is critical: if anything goes wrong, the mod gracefully degrades to vanilla behavior instead of crashing.
3. Multi-Whip Hit Effects¶
When calculating hit damage, iterate all active whips instead of checking just one:
// In ModifyHit_Prefix
foreach (var entry in dict.Values)
{
if (entry.TimeLeftOnNPC[npcIdx] <= 0 || entry.Effect == null) continue;
// entry.Effect is UniqueTagEffect — direct method calls, no reflection
if (!entry.Effect.CanRunHitEffects(owner, projectile, npc)) continue;
entry.Effect.ModifyTaggedHit(owner, projectile, npc, ref damage, ref crit);
}
4. Timer Management¶
Each frame, decrement all whip timers and clean up expired entries:
private static List<int> _toRemove = new List<int>();
// In Update_Prefix
_toRemove.Clear();
foreach (var kvp in dict)
{
var entry = kvp.Value;
for (int i = 0; i < entry.TimeLeftOnNPC.Length; i++)
if (entry.TimeLeftOnNPC[i] > 0) entry.TimeLeftOnNPC[i]--;
if (!entry.HasAnyActiveTags())
_toRemove.Add(kvp.Key);
}
for (int i = 0; i < _toRemove.Count; i++)
dict.Remove(_toRemove[i]);
The reusable _toRemove list avoids allocations per frame.
5. Maintaining Vanilla Compatibility¶
When setting a new active whip, update vanilla's internal fields so unpatched code doesn't crash:
private static void SetActiveEffect(TagEffectState tagState, int type)
{
var effect = GetEffect(type);
_effectField.SetValue(tagState, effect); // Keep vanilla _effect updated (private field)
_typeSetter.Invoke(tagState, new object[] { type }); // Keep vanilla Type updated (private setter)
// Also track in our multi-tag dictionary
MultiTagState.AddOrUpdateEntry(playerIndex, type, effect);
}
Patches Applied¶
| Method | Purpose |
|---|---|
TrySetActiveEffect |
Add whip to multi-tag dict instead of replacing |
TryApplyTagToNPC |
Tag NPC in specific whip's entry |
ModifyHit |
Apply damage from ALL active whips |
OnHit |
Fire hit effects from ALL active whips |
Update |
Tick down all whip timers, remove expired |
IsNPCTagged |
True if ANY whip has active tag on NPC |
CanProcOnNPC |
True if ANY whip has active proc on NPC |
TryEnableProcOnNPC |
Enable proc for matching whip |
ClearProcOnNPC |
Clear proc for all whips on NPC |
ResetNPCSlotData |
Clear all whip data for NPC |
Lessons Learned¶
- Prefix + return false replaces vanilla - Cleaner than transpilers when you need to replace entire method logic
- Always fall back to vanilla on error - Return true in catch blocks so crashes degrade gracefully
- Reuse collections to avoid GC pressure - Static lists for per-frame operations
- Keep vanilla fields updated - Other code (including other mods) may read vanilla state
- Reset state on world load - NPC indices get reused, so clear stale timer data
For more on Harmony patching patterns, see Harmony Basics.