Skip to content

Instantly share code, notes, and snippets.

@Alecat
Last active February 9, 2024 16:13
Show Gist options
  • Save Alecat/084ba4995b5cfa86021d2ce841717ba5 to your computer and use it in GitHub Desktop.
Save Alecat/084ba4995b5cfa86021d2ce841717ba5 to your computer and use it in GitHub Desktop.
/*
* KeyWe AutoSplitter for LiveSplit
*
* Designed to work from a save file starting from level 0
* Splits on completion of every level.
*
* Start: When the level is selected from the calendar
* Split: When the DONE button is pressed on the level-end screen
* Reset: When a different save file is loaded (including deleting a save file)
*
* Pauses the in game timer if any of the following conditions are met:
* - after a level ends
* - during the loading screen that appears after selecting a level from the calendar
* - if the in-level timer is not running
* - when no level is selected
*
* The in-game timer is resumed when a level is selected and has loaded.
* The in-game timer is recalculated off sum-of-levels when splitting - they may appear to rewind at split time to cut out times the timer was incorrectly running.
*
* Issues:
* - segment times may round incorrectly? Uncertain what method the in-game timer uses to round - seemed to be truncation in some cases, but not in others...
* - does not count time spent in a level that is restarted
*
* TODO
* Options to choose when splitting happens
* Can we tell if we've finished a level with a better check than old.currentLevelIndex < current.currentLevelIndex ? (Allows for timing non-fresh runs)
*/
/* Finding the pointers
* In CheatEngine, classes can be found in the Mono Dissector under Assembly-CSharp
* Search for the GameFlowController class and check the offset of the dataKeeper field (Currently: 0x28)
* Search for the DataKeeper class and check for the offset of the profile field (Currently: 0x68)
* Scan for instances of the DataKeeper class
*
* Determine which of the objects is the most plausible DataKeeper in use. A good way to do this is to observe the currentState value:
* currentState enum values
* 1 - Not in a level
* 2 - In a timed level (including loading the level and viewing its results. Stays in this state if choosing "Restart" on results screen)
* 3 - In an untimed level (eg overtime shift)
* 4 - Playing a cutscene
*
* In the pointer search, search for the pointer of the DataKeeper.
* Filtering by the last pointer being the offset from the GameFlowController class can be useful
* Repeat this process, filtering the results of the pointer scanner after restarting the game multiple times
*/
state("KeyWe") {
// ProfileData
ushort currentLevelIndex : "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x28, 0x28, 0x68, 0x62;
ushort playthroughId : "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x28, 0x28, 0x68, 0x66;
// int levelRecords: "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x28, 0x28, 0x68, 0x10;
// LevelLoader
bool isLevelLoading: "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x38, 0x28, 0xab;
bool isFromRestart: "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x38, 0x28, 0xac;
// isWaitingForPlayer may be useful for online games? Appears unused in local play
// bool isWaitingForPlayer: "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x38, 0x28, 0xa9;
// DataKeeper
ushort currentState : "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x28, 0x28, 0x98;
ushort activeLevelIndex : "UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x28, 0x28, 0x8e;
// ModeFeedback =20> ModeTimer =38> ElapsedTime
float activeLevelTime : "UnityPlayer.dll", 0x017CB888, 0x10, 0x100, 0x30, 0x10, 0xD0, 0x60, 0x20, 0x38;
// alternative pointer path
// float activeLevelTime : "UnityPlayer.dll", 0x017CB888, 0x18, 0x100, 0x30, 0x10, 0xD0, 0x60, 0x20, 0x38;
// Other possible paths...?
// ...? GameMode =78> ModeTimer =38> ElapsedTime
// ...? Timer => ModeTimer => ElapsedTime
}
startup {
/* TODO: settings */
settings.Add("start_anylevel", false, "Start on any level");
settings.Add("reset_change_savefile", true, "Experimental: Reset on save file change");
settings.Add("track_ingame_loads", true, "Experimental: pause IGT when not in level");
}
init {
timer.IsGameTimePaused = true;
vars.isInMenu = current.currentState != 2;
vars.hasSplit = false;
vars.gameTime = 0.0;
vars.extraAttemptsTime = 0.0;
}
exit
{
// Pause the timer if the game is exited
vars.isInMenu = true;
// Track whether a level was just completed
vars.levelCompleted = false;
}
update
{
if (current.currentLevelIndex == null) return false;
// On the split screen
if (old.currentLevelIndex < current.currentLevelIndex) {
vars.isInMenu = true;
}
// Going from calendar to level
if (old.currentState == 1 && current.currentState == 2) {
vars.isInMenu = false;
}
}
start
{
if (old.currentState == 1 && current.currentState == 2) {
if (settings["start_anylevel"]) {
return true;
}
// Only start on level 0
return current.activeLevelIndex == 0;
}
}
isLoading
{
if (!settings["track_ingame_loads"]) {
return false;
}
// Pause timer when there's no active level
if (current.currentState != 2) {
return true;
}
if (current.isLevelLoading) {
return true;
}
return true;
}
gameTime
{
// update game time on split
if (old.currentLevelIndex < current.currentLevelIndex || old.playthroughId != current.playthroughId ) {
// Recalculate all the times for all levels to be sure
vars.gameTime = 0.0;
for (int i = 0; i < 36; i++) {
vars.levelTime = new DeepPointer("UnityPlayer.dll", 0x0180E7F8, 0x128, 0x88, 0x30, 0x28, 0x28, 0x68, 0x10,
0x20 + i * 0x8, 0x2C).Deref<float>(game);
if (vars.levelTime != -1) {
// Truncate the level time to 2 decimal places (the value displayed in the UI)
// vars.levelTime = Math.Round(vars.levelTime, 2, MidpointRounding.AwayFromZero);
vars.levelTime = Math.Truncate(vars.levelTime * 100) / 100;
vars.gameTime = vars.gameTime + vars.levelTime;
}
}
return TimeSpan.FromSeconds(vars.gameTime);
} else if (!vars.isInMenu && current.currentState == 2 && current.activeLevelTime > 0.1) {
return TimeSpan.FromSeconds(vars.gameTime + current.activeLevelTime);
}
return TimeSpan.FromSeconds(vars.gameTime);
}
reset
{
if (settings["reset_change_savefile"] && old.playthroughId != current.playthroughId) {
return true;
}
}
split
{
if (old.currentLevelIndex < current.currentLevelIndex) {
vars.hasSplit = false;
return false;
}
// Split when the Done button is pressed
if (!vars.hasSplit && old.currentState == 2 && (current.currentState == 1 || current.currentState == 4)) {
vars.hasSplit = true;
return true;
}
}
@Alecat
Copy link
Author

Alecat commented Apr 24, 2023

Known issues: after the introduction of challenges, some levels will not have live times tracked during splits. The splitter should still produce the correct sum of splits after pressing the "Done" prompt at the end of a stage.

Sometimes, a wrong time is registered for a level. The sum of segments will fix itself after the end of the next level, but you may wish to manually update the split times as they might not represent realistic values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment