Mega Tree API
Events
The three events, how often each one fires, and why the recommended way to apply an effect is a getter postfix and not an event at all.
The three events
| Event | Signature | Fires |
|---|---|---|
LevelChanged | Action<string,int> |
Right after any level is bought or sold back, once. Includes Mega Tree's own nodes. |
SaveLoaded | Action<int> |
Once per load, after the levels for that slot have been read. The argument is the slot index. |
Apply | Action |
About twice a second, after Mega Tree applies its own effects. |
Per-node versions of the first and third exist on the definition itself -
OnLevelChanged and OnApply - and are usually
what you want, since they save you filtering by id.
MegaTreeApi.LevelChanged += (id, level) =>
{
if (id == "bigboxes.capacity") Logger.LogInfo($"capacity -> {level}");
};
MegaTreeApi.SaveLoaded += slot => Logger.LogInfo($"slot {slot} loaded");
MegaTreeApi.Apply += () => { /* about twice a second */ };When your mod is reinstalled and the player's old level comes back out of the save file, that is not a purchase and no event fires. Nobody bought anything.
If it did fire, a mod that applies its effect in OnLevelChanged would apply it once there and once more on the first Apply - a double effect on exactly the load where you are least likely to notice.
Applying an effect: pick the right shape
How you apply an upgrade depends entirely on how the game reads the value.
| The game... | Use |
|---|---|
| calls a property or method every time it needs the value | Harmony postfix on the getter. Best option by far. |
| reads a field once, on spawn or on enable | Apply / OnApply, writing the field. |
| rewrites the field itself every frame | Your own MonoBehaviour.Update. Apply is too slow. |
| uses the value once when something happens | A patch on that thing, reading GetLevel at the moment it happens. |
The recommended pattern: patch the getter
There is no instance lookup, no timing question, no state to keep in sync - the game asks, you answer.
[HarmonyPatch(typeof(SomeGameClass), "get_SomeInterval")]
internal static class SomeIntervalPatch
{
private static void Postfix(ref float __result)
{
int lvl = MegaTreeApi.GetLevel("bigboxes.speed");
if (lvl <= 0) return;
__result *= 1f - 0.05f * lvl; // lower interval = faster
}
}Several values in this game are computed properties, and several others are written back by the game on its own schedule. Setting the field looks like it works and then quietly stops - the game puts its own number back on the next tick. Patching the getter hands your number to the game through its own code path, so it sticks, and anything downstream follows on its own.
This one cost real time in Mega Tree itself. A coroutine that does yield return waiter; where waiter was built once will keep using the old interval no matter what your getter returns - the object holding the duration already exists.
When a getter postfix visibly does nothing to a timing, look for a cached WaitForSeconds field and write that instead.
When there is no getter: write the field on Apply
new UpgradeDefinition
{
Id = "bigboxes.capacity",
// ...
OnApply = () =>
{
int lvl = MegaTreeApi.GetLevel("bigboxes.capacity");
if (lvl <= 0) return;
foreach (var thing in Resources.FindObjectsOfTypeAll<SomeThing>())
{
if (thing == null || !thing.gameObject.scene.IsValid()) continue;
thing.Capacity = BaseFor(thing) + lvl * 2; // absolute, never +=
}
},
}Apply runs again and
again. thing.Capacity += 2 makes the value climb forever; within a
minute the player has a box that holds four thousand items. Always compute from a remembered base
value, so running it a hundred times gives the same answer as running it once.
Remembering the base value
Memoise the first value you ever saw for each object, keyed by instance id:
private static readonly Dictionary<int, float> Base = new Dictionary<int, float>();
private static float BaseOf(UnityEngine.Object o, float current)
{
int key = o.GetInstanceID();
if (!Base.TryGetValue(key, out var v)) { v = current; Base[key] = v; }
return v;
}
// Reset when a save loads: pooled objects reuse instance ids.
MegaTreeApi.SaveLoaded += _ => Base.Clear();A stopped NPC reads speed 0. If that zero becomes the remembered base, that object is multiplied by zero forever and never moves again. Skip anything at or near zero and pick it up on the next pass.
FindObjectsOfType misses anything inactive, and this game pools a lot. Resources.FindObjectsOfTypeAll<T>() finds them - but it also returns prefabs, which are not in any scene. Filter on gameObject.scene.IsValid(), as in the snippet above, and you get exactly the live objects.
What Apply is not
Apply fires roughly twice a second. It is a re-assert pass for
things that spawn while the player is playing - a newly hired employee, a customer walking in -
not a game loop.
| Frequency | Reach for |
|---|---|
| Every frame | Your own MonoBehaviour with an Update |
| Every ~0.5 s | Apply / OnApply |
| On change only | LevelChanged / OnLevelChanged |
| On demand | A getter postfix reading GetLevel |
In IL2CPP this needs ClassInjector.RegisterTypeInIl2Cpp<T>() and an (IntPtr ptr) constructor. In Mono it is the normal Unity thing: new GameObject("X").AddComponent<MyThing>() plus DontDestroyOnLoad, and you are done.
Order and isolation
Subscribers run after Mega Tree's own effect passes, each inside its own try/catch. A handler that throws is logged with your node id and does not stop the other subscribers or the built-in upgrades. Repeated identical failures are silenced after five lines so one broken mod cannot bury the log.
That protection is not a licence to be slow: everything runs on the main thread, so a handler that takes 50 ms is a visible stutter twice a second.
The log gets a [perf] summary once a minute naming any step that took long enough to be felt. Your OnApply runs inside the apply step, so a slow handler shows up there - which is the fastest way to find out that it is yours and not the tree's.
Order at load
When a save loads, the sequence is:
- levels are read from the slot file
- Mega Tree applies its own effects
SaveLoaded(slot)- the first
Applyof that session
So SaveLoaded is the right place to clear caches -
GetLevel is already correct by then, and your reset lands before the
first apply rather than after it.