Performance Patterns for Safe Event Handling
It's often the case that you need to handle a "sensitive" event in some mod code. These events are things like OnHit, OnAnimationEvent, and OnItemAdded. What these events have in common is that in some cases they can be produced in large busts.
An event handler that takes too long handling those events can result in an overloaded script system that overflows its stack and gets suspended, with unpleasant results for your game.
Another kind of event that exhibits this problem is one generated by a cloak, and it follows that trigger box events can also cause similar overloads.
In all cases, the problem is the same, efficient handling of the events. While some will say "don't use cloaks ever", that's a gross over-statement that takes a dogmatic approach to cloaks. Problems have arisen because the pattern shown in the CK cloak tutorial is quite bad, and adding scripts with cloaks is generally a bad idea, but cloaks in general are no worse than any other event that can fire rapidly in bursts - probably less bad than an OnItemAdded running on the player.
The central problem with script in these handlers is the basic problem of taking too long to execute. This is caused simply by doing too much in the handler script. Scripts that go off and potentially do heavy work are almost certain to cause an issue unless additional measures are taken.
A favorite pattern is the "busy state", which uses Papyrus built-in states to avoid overload. In this pattern, after a successful call to the event handler, additional calls to the handler will do nothing unless the first call has completed.
The code looks something like this:
Event OnItemAdded(Form addItemBase, Int itemCount, ObjectReference addItemReference, ObjectReference theSourceContainer)
GoToState("Busy") ; Enter busy state - any further calls will get the other event handler
... do some serious work ... could take a while ...
GoToState("") ; Leave the busy state
EndEvent
State Busy
Event OnItemAdded(Form addItemBase, Int itemCount, ObjectReference addItemReference, ObjectReference theSourceContainer)
; Empty function does nothing.
EndEvent
EndState
This approach is fine is you just want to know the event is being hit by something. It's good for situations like an OnHit event where you want to know the player is being hit in combat, but you don't need to handle every hit, just as many as you can - and the player won't really notice those hits you didn't process.
But, quite frequently, the reason you're running an event handler is because you need to get all the events. You really don't want to miss any. This is most likely with an OnItemAdd, where you're looking for a particular item.
An appropriate filter is the recommended solution to OnItemAdd performance risks, and that's great if you know exactly what items you are interested in, and the list is small. If the list is huge, or you don't know the items but simply that they have a certain keyword, or came from a certain mod, then filtering is probably unsuitable.
Nevertheless, where you can use a filter, that should be your first approach, as filters are very good at reducing the number of event calls, so you don't have to run a lot of code on irrelevant items at all.
But let's suppose a filter is not going to cut it. Imagine you are doing what I wanted to do recently, which is to detect every DD key the player obtains.
There are now many mods that create their own uniquely flavored DD keys, and so it's not enough just to check for the default DD keys any longer - if it ever was.
I could do this...
Keyword Property ddRestraintsKey Auto
Keyword Property ddChastityKey Auto
Keyword Property ddPiercingKey Auto
GlobalVariable Property _DFKeyFound Auto
Event OnItemAdded(Form addItemBase, Int itemCount, ObjectReference addItemReference, ObjectReference theSourceContainer)
If _DFKeyFound.GetValue() > 0.0
Return
EndIf
If addItemReference
If addItemReference.HasKeyword(ddRestraintsKey) || addItemReference.HasKeyword(ddChastityKey) || addItemReference.HasKeyword(ddPiercingKey)
_DFKeyFound.SetValue(1.0)
EndIf
Else
If addItemBase.HasKeyword(ddRestraintsKey) || addItemBase.HasKeyword(ddChastityKey) || addItemBase.HasKeyword(ddPiercingKey)
_DFKeyFound.SetValue(1.0)
EndIf
EndIf
EndEvent
There are no states needed in this case. It's not ... terrible ... the work done in the handler is strictly bounded, and once the _DFKeyFound global gets set, further calls early-out and do nothing. Though, of course, a massive inventory flood coming from emptying Sanguine's chest in SD+, or the prison confiscation chest, or similar, probably wouldn't ever set that value, and the script would run a lot of times. Possibly a couple of hundred times in a single burst - but it's not such a huge script it would bring down your game - it would just take a while to clear. But ... if you then piled more load on top, it might become an issue.
So, the above code would probably be ok, but it's still a little worrying.
It could be better. We could do less. What is the minimum we could do?
One approach could be this:
Event OnItemAdded(Form addItemBase, Int itemCount, ObjectReference addItemReference, ObjectReference theSourceContainer)
StorageUtil.FormListAdd(Player, "DF_KeyRuleInventoryAddListItems", addItemReference)
StorageUtil.FormListAdd(Player, "DF_KeyRuleInventoryAddListBases", addItemBase)
EndEvent
FormListAdd is for all intents instantaneous compared to any Papyrus execution; the cost is entirely dominated by the Papyrus overhead.
Somewhere else there is a regular service routine that pulls everything out of the lists and deals with it. How that's done properly is a topic of its own, and I don't want to get stuck in that, so let's just pretend it's easy to iterate over each list and process the items. One list is mostly going to be full of "None".
This is the least we could reasonably do in a handler, and it won't miss any items.
The down side of this is that we absolutely must service those lists reliably or they will grow without bound, and probably cause a CTD.
That is also probably an unacceptable risk. What if the Papyrus servicing the lists fails?
Here's the compromise I came up with for DF:
Event OnItemAdded(Form addItemBase, Int itemCount, ObjectReference addItemReference, ObjectReference theSourceContainer)
; The obvious risk here is that we don't service this list and it grows without bound...
; So don't allow that.
If StorageUtil.FormListCount(Player, "DF_KeyRuleInventoryAddList") < 1000
If addItemReference
StorageUtil.FormListAdd(Player, "DF_KeyRuleInventoryAddList", addItemReference)
Else
StorageUtil.FormListAdd(Player, "DF_KeyRuleInventoryAddList", addItemBase)
EndIf
EndIf
EndEvent
In this case, if the list is overflowing, we back off and stop adding - items will be lost - but this is better than creating a massively bloated list that is going to lag in processing anyway.
In fact, with a limit of 1000, if we are skipping filling the list, it's almost certainly because the servicing routine has stopped running and is dead.
Also, I took a little Papyrus time to determine whether to handle as item or base. This is a less clear-cut whether it's the right approach, but it saves time later in processing the list later, that is for sure.
The alternative is to use the approach above and fill two lists (or the same list) with both inputs and discard all the None items later. Another approach is to discard non-uniques, which will filter out all the None items at the point of add - but those calls to StorageUtil are going to start using a bit more CPU if you do that. What's best? You'd need to profile in detail to know. I couldn't be bothered going that far this time.
So, there is no straight answer to any problem, and no perfect solution. But sometimes the "busy" state pattern is not going to do what you want, and you need something else.
Busy state invariably discards a lot of calls when you get a burst. It maintains performance, but the cost is lots of lost events.
States can result in some hierarchic resolution of the state declarations, so they are more costly than they look, but I suspect it's still negligible. The problem is really losing all those calls that you made the handler to catch in the first place.
Without detailed profile results it's hard to say exactly what would be the absolute best approach here; but profiling this sort of scenario in a meaningful way is not trivial. I can easily imagine some garbage profile results, but a useful measure might be how often we get a suspended stack.
6 Comments
Recommended Comments