Reverse-engineer meh321 discovered the underlying cause of the "ability condition bug." As it turns out, the bug is less specific to abilities and to conditions, and more specific to active effects' elapsed time. It's an issue with floating-point precision, which prevents the game from properly adding small numbers (e.g. the seconds per frame, like 0.016 at 60FPS) to an active effect's elapsed time once the elapsed time has increased high enough.
If you're not familiar with floating-point imprecision, the video below examines the topic from the perspective of Super Mario 64, and will hopefully do a good job of demonstrating the idea.
It's widely known that spells' conditions update every second; what's less known is that this isn't a global timer. The game doesn't update all spells' conditions every second; rather, an active effect's conditions will update at one-second intervals after that effect has been applied. This staggers out all of the condition checks, so that the game doesn't have to do absolutely all of the work for spell conditions on one single frame, every second. The game, then, handles conditions by re-checking them when the following expression is true:
floor(elapsed_time / interval) ≠ floor((elapsed_time + frame_time) / interval)
So if we can't add the frame time (seconds per frame) to the elapsed time, then we can't re-process conditions. When an effect has been active for about three real-world days, the elapsed time in seconds will be high enough that attempting to add 0.016 to it (the frame time for 60FPS) produces no change in the value. This problem mainly affects abilities, which track elapsed time despite being temporary, but it also affects temporary effects with long durations: the elapsed time itself can't advance, so if someone makes a spell with, say, a 7-day duration, then that spell will effectively be endless.
There's a wrinkle here: fast-traveling, waiting, sleeping, and serving time in jail will simulate the passage of real-world time. If you use the default timescale of 20, then 5 in-game hours will pass in 900 real-world seconds; accordingly, if you skip 5 in-game hours by waiting or a similar means, the game will actually perform that conversion in reverse and advance real-time timers on actors (including the elapsed times for active effects) by 900 seconds. These advancements can be enough to overcome floating-point imprecision, allowing stuck effects to reprocess conditions (or expire) for just one frame.
The approach that Cobb Bug Fixes takes is to patch the code that advances the elapsed time, and the code that checks the condition update interval, to synch both to a custom timer if the elapsed time is high enough for the vanilla approach to fail. Our custom timer ticks up from zero and resets one frame after it exceeds the condition update interval; broken active effects will act when the timer exceeds the interval. In other words: if we can't advance the elapsed time by 0.016 every 0.016 seconds, then let's advance it by 1 second every 1 second. With this change, conditions should always reprocess correctly (because we use a looping timer divorced from the elapsed time), and the elapsed time should properly advance until an effect has been active for about 194 real-world days.
It must be noted that I was only able to test this fix "in the lab:" during testing, I used a version of the fix that always used my custom timer, even for effects with small elapsed times. I don't think I have any PC saves available that not only have condition-sensitive abilities on the player, but that have had those abilities for longer than three straight days' worth of playtime.
8 comments
As it stands, the current approach is to use a shared cyclical timer for all active effects whose elapsed times are now too distant to use as a timer. This shared timer is one thing in one place, easy to access from anywhere.
Each of the update points is essentially an object that has a cyclical timer with a period of one second and a list of magic effects that are assigned to it. The timer is inactive by default, as long as the list is empty. When a new effect is assigned to the update point, a check is made whether the list was empty before the assignment, in which case the timer gets activated. Likewise, when an assigned magic effect ends (elapsed time exceeds its duration or external dispel via script or dispel effect), it gets removed from the list, and a check is made whether the list is now empty, in which case the timer is deactivated. This way, only the timers that are needed at the moment are ever active, and the amount of checks needed for determining the activation status is kept to a minimum. Also, the magic effects themselves need no additional data on them, because the timers run indepently of the effects and call updates on their assigned effects every second.
The timers between the n different update points are spread apart by one nth of a second. There is a global list of least occupied update points that is updated whenever one of the points has one of the aforementioned checks. Whenever a new magic effect starts, it gets randomly assigned to an element of that list. This assures that the game never has to check too many conditions on the same frame.
So essentially what I mean is n potentially shared cyclical timers that circumvent the original update code completely, but still manage to spread apart the update events reasonably, which, as I understand from your article, is the sole reason for the janky original code existing in the first place. The problem I see with one shared cyclical timer that stores all the effects that fall victim to the jank is that it might get overfilled eventually, so that whole singing and dancing about spreading the updates apart in the first place becomes useless.
One possible performance hazard is that I'd have to store the active effects' memory addresses in some kind of map or list, since as mentioned adding more data (such as information on what timer they're sharing) to the ActiveEffect itself is difficult. Lookups could incur a small performance cost, and we'd need to do those lookups every time the timer needs to be checked (i.e. every frame, for every effect).
I'm currently occupied with other projects, but I can pursue this line of investigation if people report performance issues in long-running savegames, particularly if the reported issues go away when this mod is disabled.
I can't imagine that those lookups would incur more cost than the actual condition checks on the magic effects themselves, and the game seems to manage quite a lot of them every second, so I'd assume that wouldn't be the bottleneck. You wouldn't have to do it every frame, only on the frames that coincide with the zero pass of one of the update point timers, so that is some reason for careful optimism with regards to performance, I suppose.
We have spell (ability, whatever) with long duration. I will refer its current uptime as [X].
Time range = seconds - seconds (hh:mm:ss - hh:mm:ss)
Under what conditions a bug will happen:
If [x] = 131072 - 262144 (36:24:32 - 72:49:04) and your FPS > 128
If [x] = 262144 - 524288 (72:49:04 - 145:38:08) and your FPS > 64
If [x] = 524288 - 1048576 (145:38:08 - 291:16:16) and your FPS > 32
If [x] = 1048576 - 2097152 (291:16:16 - 582:32:32) and your FPS > 16
Guide - how to quickly test it yourself:
install this esp: https://mega.nz/#!aHJliCgb!UPV9kJImqYokFpDkUQEnh7RsGDRPjixbBn7It8ytV5g
cap fps to 20
launch game
player.addspell xx000801 (where xx = place of my esp)
set timescale to 0.0033
use ACB_test_SPEL
"wait menu" > wait 1 hour (game can freeze for about 1 minute, it's ok)
check active effects - "Time left" won't change!
If you have plans on other 2 bugs fixed by meh and need help in testing - let me know.
And another thanks for not dropping LE and especially for doing such godlike job with those fixes!