-
-
Notifications
You must be signed in to change notification settings - Fork 8
IGT in soulsgames explained
I get asked a lot of questions about technical details about the timers in all the soulsgames. In this document I will try to write up (in non-programmer language) how the base timers work in these games and how that base behavior is changed with the livesplit plugin to make the timer more fair.
- There are 2 technical topics to explain first; decoupling physics engines from framerate and truncating numeric datatypes.
- After that I will explain how these topics come into play in Dark Souls 3, Sekiro: Shadows die twice and Elden Ring.
- Then I will explain how we deal with the problems in the plugin.
- I will explain why the timer can jump back slightly after quiting and reloading a savefile.
- I will touch on blackscreen removal briefly.
- At last I will write about Dark Souls 1 and 2 and how they differ from the other games.
Without further ado, lets get to it.
Modern approaches to game engines employ some tricks to decouple updating the physics engine and internal state from the rendering engine. That means a game can be rendered at 30/60/120/144 FPS but still have the same physics characteristics - holding down forward will move you the same distance in-game at 30/60/120/144 FPS.
To achieve this, the render engine time how much time it takes to render a frame. This is called the frame delta, the delta time between rendering 2 frames. At 60 FPS, this frame delta will be 16.666 milliseconds. If you suddenly have a big lagspike, and only get 30 FPS for a second, the frame delta would change to 33.333 milliseconds.
This change in delta time can be used as reference to the physics engine to move moving objects twice as far for this lagspike. This compensates for the lag. The secondary effect is that the game can be run at any framerate.
This topic can be boring so I'll try to be brief. Computers can look at raw 1's and 0's in different ways - treat them as if they are text, numbers, etc. This is called datatypes. There isn't just 1 numeric datatype, there are lots of them. The types relevant for timers in soulsgames are floats and ints. An int is a whole positive or negative number and a float is a fraction, positive or negative. An integer can never have a decimal part, only the whole part. When adding a float to an integer, you lose whatever fraction the float has, because the integer can not represent this part. That means that we need to either round the floating point number to the closest whole, or we need to truncate it. When truncating it (also known as casting) we take whatever whole the float has, completely discard any decimal portion, and add that whole to the integer.
Credit where credit's due: Initial analysis of the problems with the timer was done by B3LYP, he wrote a PDF that explains the problem at a technical level. It's also B3's code that is still in soulsplitter to this day to fix the timer in Elden Ring and Sekiro.
With that out of the way, lets jump in. There are 2 locations where IGT lives: one is in the savefile, the other is in memory. Both of these are in integer form, and store the total amount of milliseconds played on the character in question. This is a very common way to store time on computers - you can calculate the amount of hours:minutes:seconds from the amount of milliseconds on the fly, while you only have to store 1 value. Cheat engine and ghidra are used to find the location of IGT in memory, from there it can be used in a livesplit plugin to show IGT. When testing this implementation, you will immediately notice that the timer runs about 4% slower than time runs in the real world. You will also notice the framerate affecting the timer. When reverse engineering the game's code, we can find where the timer is incremented.
A frame is rendered, the delta time between frames is obtained. This delta time is passed to different portions of the game's code to update different systems. One of those systems is increasing IGT. The delta time at 60 FPS is 16.666 milliseconds. The game will then truncate this value to 16 milliseconds, and increase the timer by 16 milliseconds. 0.666 milliseconds of precision is lost. This explains right away why the timer in-game is running slower than the time in the real world. We lose 0.666 milliseconds of time on every single frame.
If we look at delta time for different framerates, we can see right away how different framerates will affect the timer. 30 FPS - 33.333 -> 0.333 truncated every frame. 60 FPS - 16.666 -> 0.666 truncated every frame. 120 FPS - 8.333 -> 0.333 truncated every frame.
You can see that due to the nature of the truncation, the amount of precision lost fluctuates - the lower the decimal portion, the more accurate the timer is. Once you cross from a high decimal value (0.99) back over to a low value (1.01) you can observe the timer from drifting at maximally to drifting minimally.
This is why lag will mess with your time, either positively or negatively - it's very hard to say and complete RNG if the framerates you get during a lagspike will positively or negatively influence the timer. For a speedrun, the most important thing is to have consistent times for each runner, so something has to be done to fix this problem.
It is technically unfeasible to change the datatype of IGT without having the source code of the game. Instead, we have to change the code surrounding incrementing IGT. First, we allocate a memory location to store a float. Then, when the function to increase IGT is called, we execute our own code first:
- Truncate the float again, but this time the other way around. Removal the whole number, 16, and only keep the decimal, 0.666.
- increase the float we allocated by this number, since we started at 0, it is now 0.666.
- Check if our float is bigger than 1.0. It is not, continue with the original code
- Float is truncated to 16, IGT is increased by 16, .666 of precision is lost.
Next frame, the function is called again and we start executing our own code:
- Truncate the float again, increase our own allocated float by .666. The value is now 1.333.
- Check if our float is bigger than 1.0. It is!
- Increase IGT by 1.0 - we win back our lost 0.666 of precision from the previous frame, and we account for half (0.333) of the lost precision of the current frame.
- Decrease our float by 1.0, current value is now 0.333
- Float is truncated to 16, IGT is increased by 16, lost precision is at 0.333
Next frame
- Truncate float, increase our own float by .666, value is now 0.999
- Check if float is bigger than 1.0, it is not
- Float is truncated to 16, IGT is increased by 16, lost precision is at 0.999
Next frame,
- Truncate float, increase our own float by .666, value is now 1.665
- Check if float is bigger than 1.0, it is!
- Increase IGT by 1.0 - we win back lost precision of 1 and a half frame.
- Decrease our float by 1.0, current value is now 0.665
- Float is truncated to 16, IGT is increased by 16, lost precision now at 0.665
You can see now that instead of letting the loss in precision build up by 0.6 every frame, it is correct for whenever possible and can never exceed 1.0 milliseconds. The timer will now run at the same speed, regardless of framerate or lag, for every runner.
A new problem does arise though - the timer now runs at the same speed as the time does in the real world. That means that people refusing the use the livesplit plugin will have a slower timer, and this have favorable times. This is fixed by just multiplying the time values and having the correct IGT run 4% slower, to match with the stock timer. The times will now make sense and sort of match up with runners that don't use the plugin or console runners.
This fix is known as MIGT or modified IGT.
This fix is applied to Elden Ring and Sekiro, but not to Dark Souls 3.
You may have observed the timer jumping back a little bit when reloading a save after quiting out. Presumably this happens because the IGT value in memory is copied to the savefile early on during the saving process. The timer is still being incremented while the rest of the data is being saved, and the game is starting to fade out. The timer in memory has a higher value then the timer in the savefile. When the game is loading, it will copy the lower value from the savefile into the memory IGT value, which causes the timer to jump back a bit.
Blackscreen removal works by looking at certain memory values in an object called the "FadeManager". This object is presumably responsible for fading the game in and out when loading cutscenes or blackscreens. Once the plugin detects that these memory values are signaling a fade in/out, the plugin will take the last good value of IGT, and overwrite the game's IGT value with this last known value on every frame. This causes the timer to give the illusion of being paused - in reality the game is still incrementing it's value every frame, the plugin is just undoing that action. A similar technique is applied to 3 loading screens in Dark Souls 3, where even though the game is loading the next area, the IGT value still gets increased every frame.
This fix is applied to Dark Souls 3, Sekiro and Elden Ring.
Dark Souls 1 does not have it's internal systems decoupled from the framerate. If the rendering portion of the game is lagging, the whole game lags with it, including IGT - IGT is effectively a framecounter. That makes it consistent for each runner, no fix is needed. Dark Souls Remastered works the exact same way, it just runs at a different framerate, and increases IGT by a different constant every frame. Once again, no fix is needed.
Yeah... Dark Souls 2 is weird. While there is a memory value that could be interpreted as IGT, it resets to 0 every time the game is autosaving, or when you quit and reload the save. If you completely halt the game with external tools, to simulate intense lag, you can see that the timer does not care about lag at all and just keeps running. Some further analysis shows that the timer is just asking windows for the current date and time and subtracts the previous date and time. Unfortunately none of these values can effectively be used in a timer in a reliable way for the speedrun.
I hope this answers any questions you may have had. Good luck on the runs!