Spell Expiry - GrognardsFromHell/TemplePlus GitHub Wiki
This section is dedicated to how spells are (or should be) ended.
It is an important and yet at the same time fragile aspect of the ToEE engine.
Getting this wrong (and it is easy to get it wrong) can result in:
- Spell permanency
- Particle effect permanency
- Crashes due to invalid handles (mitigated in Temple+)
- Unterminated spells (can slow down the game)
TOC
Prior knowledge
This section assumes you are already acquainted with the following:
- Modifiers (a.k.a Conditions) - the internal processes of spell expiry makes heavy use of various D20 Signal Events and such, which are responded to by the various spell conditions.
- Spell Engine Internals - Active Spell List (ASL), SpellPackets
Expiry Flows
This section details the flows of various spell termination cases.
Instant Effect
-
The simplest use case is when the spell effect is instant, with no lingering effects. This mainly applies to direct damage / heal spells.
-
In this case, there is usually* no persistent condition being added, and the effect is applied in the spell script itself (e.g. the OnSpellEffect or OnEndProjectile events).
-
Usually, you will see the following code at the end of the script:
spell.target_list.remove_target( target_item.obj )
# for single target spells
spell.target_list.remove_list( remove_list )
# for multi-targets;remove_list
is constructed from objects inspell.target_list
.
spell.spell_end( spell.id )
spell.spell_end
, as described below, is in charge of removing the spell from the ASL. This is basically all that's needed to expire spells of this type.
remove_target
andremove_list
are used to clear the spell.target_list.
Here is why it's important:- When spell_end is called, it internally checks that the target_list is empty.
- If the target_list is not empty, it will not actually end the spell!
- A common mistake in several fan-made spells is to forget about clearing the target_list in such instant-effect spells, which causes them to never actually expire.
Besides never releasing unneeded entries in the ASL, it causes the OnBeginRound event to continue ticking for every spell cast of this type. This can add up, and eventually even slow the game down!
-
*Notable exceptions:
- Magic Missile does apply a condition ('sp-Magic Missile'). This is an ephemeral condition that promptly removes itself after applying the damage, all during its OnAdd event. I think the purpose here is mainly to allow other conditions to react to it in a modular fashion (i.e. without hardcoding inside the MM script itself).
- Poison applies a 'Poisoned' condition, which obviously is lingering. However, it is not 'linked' to the spell after it is applied, and manages itself on its own like normal Poison from a spider bite, so the spell can safely expire.
Duration countdown
-
The other common effect type is one with some duration.
Here, the spell script will usually add a condition as in the following example:target_item.obj.condition_add_with_args( 'sp-Bless', spell.id, spell.duration, 0 )
-
It may also collect a
remove_list
, as with the instant effect type. Often, this is for targets that make their saving throws and don't get a condition applied to them, or where the condition_add fails.
This is then followed at the end of the spell withtarget_list.remove_X
spell.spell_end(spell.id)
Similar to what you see in the instant effect spell.
However, unlike the instant effect type spell, here we have an ongoing active spell! Why should it be removed from the ASL then?
The reason is - if no targets are actually affected, then we do want the spell to end.
As we know,spell_end
will only actually end the spell when the target_list is empty. In the normal case, this will simply have no actual effect, and is just meant as a safeguard. -
The spell condition usually has an OnBeginRound event handler.
- This event generally fires every 6 seconds (or every combat round when in combat mode), and most of these spells utilize the generic callback SpellModCountdownRemove().
- This callback counts down the duration variable in the spell condition, as well as update the spell's remaining duration in the ASL.
Note that there can generally be several of these for multi-target spells, but the counts stored in the duration variables should be identical. - When the countdown expires (reaches below 0), it normally triggers Spell_remove_spell() and Spell_remove_mod().
- The first knocks the targets off the
target_list
, and when the last one is cleared, will end the spell. - The latter removes the spell condition from the individual target.
- The first knocks the targets off the
- See Function Reference section for more detail on what these functions do in more detail.
Dismissal
- Spells that can be dismissed will add the “Dismiss” condition.
This allows you to perform a "Dismiss Spell" action, which will send a d20 signal S_Dismiss_Spells with the spell ID. - The “Dismiss” condition has a handler for this event. It will generate additional S_Dismiss_Spells signals for the spell’s Target List and AoE object.
- SpellDismissSignalHandler is usually triggered at this point, if it hasn’t already.
As noted below, this invokes Spell_remove_spell and Spell_remove_mod, using the same event spec (D20 signal S_Dismiss_Spells).
Death
Todo
Stop Concentration
- Spells requiring concentration add a secondary condition to the caster: 'sp-Concentrating' (with the spell ID as arg).
- This sub condition opens up the Stop Concentration action, and also reacts to action sequences and events that can break concentration.
- The Stop Concentration action sends the D20Signal as such:
d20_send_signal( performer, S_Remove_Concentration, performer) - The ‘sp-Concentrating’ condition has a S_Remove_Concentration signal handler, whose callback is Spell_remove_mod.
- As detailed below, Spell_remove_mod has a special case effectively dedicated for S_Remove_Concentration, wherein it sends a S_Concentration_Broken signal on the condition attachee and also the spell’s target list.
- Example: "sp-Animal Trance" has 2 signal handlers for S_Concentration_Broken: Spell_remove_spell, Spell_remove_mod
- When a spell with concentration expires due to countdown, it is expected to send a S_REMOVE_CONCENTRATION d20 signal to also remove the 'sp-Concentrating' condition. This should 'normally' be part of its spell_remove special casing. In practice only Animal Trance and Wall of Fire do this... this is a bug, albeit a minor one since taking an action may break the concentration.
Sub-Modifier handling
-
Some Modifiers create an additional Sub-Modifier. This is useful when the same effect is generated by different conditions.
Examples: “Invisible”, “Temporary_Hit_Points”, “Paralyzed” -
The Sub-Modifier itself may feature the following event handlers:
- D20Signal S_Spell_End:
D20ModsSpellEndHandler - D20Signal S_Killed: ConditionRemove
- OnBeginRound: D20ModCountdownHandler
- D20Signal S_Spell_End:
-
Example spell with sub-modifier: “sp-Invisibility” Applied with: condition_add_with_args( 'sp-Invisibility', spell.id, spell.duration, 0 ) sp-Invisibility has an OnConditionAdd handler that applies the sub-modifier “Invisible”
Function reference
The following is a list of functions commonly used in the process. The Python API refers to how these functions are exposed as methods to:
- The PythonModifier class - this is denoted with
pymodifier.MethodName()
. This directly adds the function as a standard hook from the engine. - The EventArgs class - this is denoted with
args.method_name(...)
For full lists of methods & properties, see:
PythonModifier
tpdp.ModifierSpec
tpdp.EventArgs
ConditionRemove (0x1004D5F0)
-
The basic function for removing the condition.
-
Python API:
args.condition_remove()
-
Note: This will remove the modifier condition for which the handler is spawned from, naturally.
To remove a different (other) condition, you will have to invoke an event that the other condition responds to, where its own handler calls ConditionRemove. Usually for spells this is done through the D20 Signal S_Spell_End.
Spell_remove_spell (0x100D7620)
-
The central spell removal function. Includes all the specialized spell removals in a huge switch case, including a default remover.
-
Python API:
args.remove_spell()
# removes spell using the same event args (event type, key, event obj etc.)
args.remove_spell_with_key(evt_key)
# same, but using a different Event Key than the original event
# E.g. to convert S_TouchAttack to S_Concentration_Broken so Spell_remove_spell handles it despite not having the correct spell ID. (see spell Touch of Fatigue)
-
Gate keeping:
-
For d20 signal handling: Checks Signal type, and matches spell ID (event arg == condition arg(0) ).
The following events will forego matching spell ID:
S_Killed, S_Sequence, S_Critter_Killed, S_Spell_Cast, S_Concentration_Broken, S_Action_Recipient, S_TouchAttackAdded, S_Teleport_Prepare, S_Teleport_Reconnect I.e. they are catch all spell removers, regardless of spell ID. -
Likewise for non-d20signal events (does that ever happen?? I think I’ve seen one case at least...)
-
-
Tries to fetch the spell from the spells cast registry. If none is found, returns without doing anything. Remember that non-active spells will be pruned on map transfer & game save. Probably causes bugs...
-
What it does:
-
Enters a huge switch-case table by spell_enum.
This is the core of the function, and includes all the special case handling. -
The default case removal (this will be the case for all new spells basically):
d20SendSignal( attachee, S_Spell_End, spell_id, 0)
EndSpellParticlesForTargetObj( attachee )
SpellSoundPlay( OnEndSpellCast )
SpellRemoveObjFromTargetList( attachee ) - Note that if this fails, it returns from the function! (i.e. quits the Spell_remove_spell process, though it may be called again)
SpellEnd( spell_id ) -
Customized spell removals may do the following: (not necessarily in this order)
- d20SendSignal( spell.caster, S_Spell_End, spell_id, 0)
- d20SendSignal( spell.aoe_obj, S_Spell_End, spell_id, 0)
- Iterate target list and end their associated spell PFX, send signal S_Spell_End, and then remove from spell target list.
- Float some text message
- Play a particular particle effect, such as “Fizzle” or “sp-XXX-END”
- Enlarge/Reduce related changes to obj_f_speed_run etc.
- Special handling for Invisibility (S_Sequence…)
- Special handling for item enchantments (e.g. Magic Weapn / Keen etc)
-
-
Notes: The D20 Send Signal S_Spell_End will usually take care of sub-modifiers (e.g. “Invisible”). Sometimes it does the D20 Send Signal on the caster instead of the attachee. I’ve seen this cause a bug at least once, when the spell target is different than the caster (e.g. sp-Invisibility; fixed in Temple+).
Spell_remove_mod (0x100CBAB0)
-
This is often called right after Spell_remove_spell. But sometimes also without it.
-
Python API:
args.remove_spell_mod()
-
Has similar gatekeeping to Spell_remove_spell.
-
It has a switch case on the condition's static parameter (data1). Normally this just calls ConditionRemove.
-
There is one special case (data1 == 2) where it also sends S_Concentration_Broken to the attachee and target list. The condition sp-Concentrating has this.
SpellModCountdownRemove (0x100DC100)
-
Python API: You can add this standard callback by using e.g.
pymodifier.AddSpellCountdownStandardHook()
-
Ticks down the duration by the specified amount in evt_obj data1 (can be more than 1 round e.g. when resting)
-
When the new duration dips below 0, it normally:
- Floats a spell expired message.
- Invokes Spell_remove_spell (using the same event spec)
- Invokes Spell_remove_mod afterwards (again, same event spec)
- Has failsafe for spells that aren’t in the Active Spell List anymore - does Spell_remove_mod for those.
D20ModsSpellEndHandler (0x100E9680)
- Gates by checking spell ID (arg0) is equal to event object data1.
- Features a bunch of special-case condition removers (e.g. for ‘Sleeping’, ‘TempHp’, ‘Charmed’ etc). These look like fail-safe removers, since the log says ‘forcibly removing X’.
- The only two who actually do anything out of the ordinary are RemoveInsibile and RemoveTimedDisappear.
- At the end calls ConditionRemove.
- There's no Python API here, since it can be fairly easily replicated in Python with a hook that calls args.condition_remove() + whatever special handling your Modifier needs.
D20ModCountdownHandler (0x100EC9B0)
- When the new remaining duration goes below 0, calls D20ModCountdownEndHandler
D20ModCountdownEndHandler (0x100E98B0)
- Almost identical to D20ModsSpellEndHandler.
- The difference seems to be that it also handles more event types, not 100% sure.
SpellDismissSignalHandler (0x100DE3F0)
- Gating: Checks spell ID matches EventObj arg Checks that the spell is active Checks that attachee == spell’s caster
- Normally does a loop over target count. For each iteration does Spell_remove_spell and then Spell_remove_mod.
- Python API: not available. TODO (but can be fairly easily replicated)
AoeSpellRemove 0x100D3430
Todo
SpellEnd (0x10079980)
- Called from:
- Spell_remove_spell, as noted above
- Frog Tongue effect ender
- PotionOnRemoveEffect (OnRemoveEffect handler for potion effects)
- Python API: called via spell.spell_end( spell.id, [force_end])
- Gating: Normally checks that the target list is empty before executing!
It has a flag for overriding this however. In the Python API this is an optional 2nd argument (force_end). - What it does: SpellTrigger( spell_id, OnEndSpellCast) - plays the python spell script OnEndSpellCast Removes spell from PySpellList Flags spell as inactive in the ASL. This will usually cause the spell to be pruned from the ASL on map change or save.
SpellTrigger (0x100C0180)
- Executes python spell events, such as OnSpellEffect, OnSpellEnd etc.
- Also triggers san_spell_cast on each target in the Spell’s target list.
- This is done when the event is OnSpellEffect.
- If any of these returns 0, it will not execute the OnSpellEffect event!
- Updates the spell on the Active Spell List with the modifications made on the python side.
Auxilliary functions
SpellPacket methods:
EndSpellParticlesForTargetObj( objHnd ):
Searches for objHnd in target list, and if found, removes the associated particle system associated (if valid ID is also found). I suspect particle permanency issues may arise if this falters, e.g. if the target list is messed with.
SpellRemoveObjFromTargetList (objHnd):
Searches objHnd in spell target list. If found, removes it, and also updates the spells cast registry with this change.