-
-
Save RefinedHornet/70453f953d536229d093a7f86b4559cd to your computer and use it in GitHub Desktop.
LiveSplit plugin for Sekiro: Shadows Die Twice
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
Sekiro Speerunning Plugin by B3 | |
v1.9 | |
Features: | |
- Fixed timer implementation. | |
- Auto start | |
- No logo mod. | |
- No tutorial mod. | |
Patches: | |
23/04/19 v1.1 | |
- Compatibility for game version 1.03 | |
- fixed timer flickering. (thanks CapitaineToinon) | |
29/10/20 v1.2 | |
- Made IGT work for game version 1.06 (contributed by 56#1363) | |
30/12/20 v1.3 | |
- Fixed the addresses for no logo, tutorial skip, and igt fix code location (contributed by RefinedHornet#4765) | |
03/01/21 v1.4 (contributed by RefinedHornet#4765) | |
- Now by default, timer only automatically starts when a new game is started or when the next new game cycle is started | |
- Included a Practice/Testing mode that auto starts similarly to old versions | |
- Introduced a offset value that takes the igt value from timer start and subtracts from current igt value to always start the timer at 0, except for new games | |
15/01/21 v1.5 | |
- Added auto start for guantlets (contributed by RefinedHornet#4765) | |
19/01/21 v1.6 | |
- Compatibility for game version 1.05 (contributed by RefinedHornet#4765) | |
27/02/21 v1.7 | |
- Fixed a bug where the script couldn't detect the version between 1.05 and 1.06 if the script was initiated again after the patches were applied (contributed by RefinedHornet#4765) | |
06/03/21 v1.8 (contributed by RefinedHornet#4765) | |
- Fixed a bug where files with over 600 hours had countdown style timers | |
- Made the precision of rounding a variable for ease of change, removed casting of Round method return to float to avoid rounding errors | |
08/03/21 v1.9 (contributed by RefinedHornet#4765) | |
- Added functionality to reduce the igt if it is near the maximum of 999:59:59 when starting the timer to prevent the timer from stopping when it reaches the maximum | |
- Added functionality to handle starting the next new game cycle if the new game cycle value is at the maximum of 9999 | |
- Cleaned up the start code | |
If you have issues or questions message me (B3LYP#2159) | |
on the Sekiro Speedrunning Discord (https://discord.gg/DVXvRPu) | |
Technical: | |
This plugin modifies the in-game timer of Sekiro to make it run at the correct | |
speed independent of frame rate. The fix as C++ code for reference: | |
''' | |
void updateTimerOriginal(float frame_time){ | |
igt += static_cast<unsigned int>(frame_time); | |
} | |
void updateTimerNew(float frame_time){ | |
static float frac = 0.f; | |
frac += frame_time - static_cast<unsigned int>(frame_time); | |
if(frac >= 1.f){ | |
frame_time++; | |
frac--; | |
} | |
igt += static_cast<unsigned int>(frame_time); | |
} | |
''' | |
*/ | |
state("Sekiro", "1.02") | |
{ | |
uint igt : 0x3B47CF0, 0x9C; | |
int skillPoints : 0x3B47CF0, 0x8, 0x154; | |
int newGameCycle : 0x3B47CF0, 0x70; | |
float posX : 0x3B67DF0, 0x48, 0x28, 0x80; | |
float posZ : 0x3B67DF0, 0x48, 0x28, 0x84; | |
float posY : 0x3B67DF0, 0x48, 0x28, 0x88; | |
} | |
state("Sekiro", "1.03") | |
{ | |
uint igt : 0x3B48D30, 0x9C; | |
int skillPoints : 0x3B48D30, 0x8, 0x154; | |
int newGameCycle : 0x3B48D30, 0x70; | |
float posX : 0x3B68E30, 0x48, 0x28, 0x80; | |
float posZ : 0x3B68E30, 0x48, 0x28, 0x84; | |
float posY : 0x3B68E30, 0x48, 0x28, 0x88; | |
} | |
state("Sekiro", "1.05") | |
{ | |
uint igt : 0x3D5AA20, 0x9C; | |
int skillPoints : 0x3D5AA20, 0x8, 0x154; | |
int newGameCycle : 0x3D5AA20, 0x70; | |
float posX : 0x3D7A140, 0x48, 0x28, 0x80; | |
float posZ : 0x3D7A140, 0x48, 0x28, 0x84; | |
float posY : 0x3D7A140, 0x48, 0x28, 0x88; | |
} | |
state("Sekiro", "1.06") | |
{ | |
uint igt : 0x3D5AAC0, 0x9C; | |
int skillPoints : 0x3D5AAC0, 0x8, 0x154; | |
int newGameCycle : 0x3D5AAC0, 0x70; | |
float posX : 0x3D7A1E0, 0x48, 0x28, 0x80; | |
float posZ : 0x3D7A1E0, 0x48, 0x28, 0x84; | |
float posY : 0x3D7A1E0, 0x48, 0x28, 0x88; | |
} | |
startup | |
{ | |
settings.Add("Practice", false, "Practice/Testing"); | |
settings.SetToolTip("Practice", "Always starts the timer from loading a save file. For practicing or testing a section"); | |
} | |
init | |
{ | |
/*We have to wait a little for SteamDRM to decrypt the exe. | |
This could potentially cause issues. Replace with code that | |
detects decryption if necessary | |
*/ | |
System.Threading.Thread.Sleep(3000); | |
vars.game_is_patched = false; | |
vars.internal_time = (uint)0; //variable used to cache igt | |
vars.offset_time = (uint)0; //variable to the offset igt | |
vars.max_igt = (uint)3599999000; //maximum igt on a save file | |
vars.reduced_igt = (uint)36000000; //variable for reducing the igt near max - equal to milliseconds | |
vars.isOffsetSet = false; //variable to hold if offset time is set | |
vars.posPrecision = 1; // precision to round position variables | |
//variables to hold reflection starting positions, for Gyoubu, Guardian Ape, and Emma, respectively | |
vars.gauntletStartingPosX = new List<double> { System.Math.Round(-100.3826447f, vars.posPrecision), System.Math.Round(-626.7877808f, vars.posPrecision), System.Math.Round(-103.5090027f, vars.posPrecision) }; | |
vars.gauntletStartingPosZ = new List<double> { System.Math.Round(-69.8219986f, vars.posPrecision), System.Math.Round(-296.0f, vars.posPrecision), System.Math.Round(53.98899841f, vars.posPrecision) }; | |
vars.gauntletStartingPosY = new List<double> { System.Math.Round(38.01242065f, vars.posPrecision), System.Math.Round(757.6397705f, vars.posPrecision), System.Math.Round(243.125f, vars.posPrecision) }; | |
// starting position for new game | |
vars.ngStartingPosX = System.Math.Round(-304.803772f, vars.posPrecision); | |
vars.ngStartingPosZ = System.Math.Round(-53.81000137f, vars.posPrecision); | |
vars.ngStartingPosY = System.Math.Round(305.3302002f, vars.posPrecision); | |
switch(modules.First().ModuleMemorySize){ | |
case 67727360: | |
version = "1.02"; | |
break; | |
case 67731456: | |
version = "1.03"; | |
break; | |
case 70066176: | |
// if the first 4 bytes at found logo code matches 74 30 48 8D or 75 30 48 8D to account for changes already being applied | |
vars.logoCodeBytesPointFive = memory.ReadValue<uint>(modules.First().BaseAddress + 0xE1B1AB); | |
vars.logoCodeBytesPointSix = memory.ReadValue<uint>(modules.First().BaseAddress + 0xE1B51B); | |
if(vars.logoCodeBytesPointFive == 0x8D483074 || vars.logoCodeBytesPointFive == 0x8D483075){ | |
version = "1.05"; | |
} | |
else if(vars.logoCodeBytesPointSix == 0x8D483074 || vars.logoCodeBytesPointSix == 0x8D483075){ | |
version = "1.06"; | |
} | |
else{ | |
print("Unknown version detected"); | |
return false; | |
} | |
break; | |
default: | |
print("Unknown version detected"); | |
return false; | |
} | |
vars.igtBasePointer = 0; // base pointer for the igt | |
//TutorialMsgDialog (Tutorial popups that pause the game) | |
IntPtr TutorialMsgDialog = (IntPtr)0; | |
//CSTutorialDialog (Little noise making tutorial popups that come up on the left) | |
IntPtr CSTutorialDialog = (IntPtr)0; | |
//CSMenuTutorialDialog (Tutorial messages in the pause and warp menus) | |
IntPtr CSMenuTutorialDialog = (IntPtr)0; | |
// locating message dialog addresses | |
// Cheat Engine -> Memory View | |
// in the memory view window | |
// Tools -> Dissect Code -> select sekiro.exe -> Start | |
// wait until the process is complete | |
// View -> Referenced strings | |
// search for each string for respective dialog | |
// TutorialMsgDialog -> "WindowName/Text" | |
// CSTutorialDialog -> "LineMessageWindow_%d" | |
// CSMenuTutorialDialog -> "MessageTextureWindow" | |
// click the address that references the string | |
// scroll up until some address and "(Call)" is found | |
// click that address(Call) to be taken to the calling address | |
// scroll up until a je instruction is found, this is the address you are looking for | |
// to confirm, add a breakpoint to the instruction and trigger the dialog in-game. if the instruction is correct, the game should pause only when the dialog is triggered. | |
//no logo | |
IntPtr noLogo = (IntPtr)0; //no logo mod | |
// Cheat Engine -> Memory View | |
// in the memory view window -> Search -> find assembly code | |
//je * | |
//lea rdx,[rsp+30] | |
//mov rcx,rbp | |
//call * | |
//nop | |
//mov ebx,00000001 | |
//mov [rsp+20],ebx | |
//movzx r9d,byte ptr [rsi+04] | |
//movss xmm2,[rsi] | |
//mov rdx,rax | |
//mov rcx,rdi | |
//call * | |
//mov rdi,rax | |
//timer mod | |
IntPtr igtFixEntryPoint = (IntPtr)0; | |
//cvttss2si rax, xmm0 <--- | |
//add [rcx+9Ch], eax ; timer_update | |
//mov rax, cs:qword_143B47CF0 | |
//cmp dword ptr [rax+9Ch], 0D693A018h | |
//jbe short loc_1407A8D41 | |
IntPtr igtFixCodeLoc = (IntPtr)0; //Start of TutorialMsgDialog constructor. This is dead code after applying the no-tut mod so the timer mod can be injected here | |
// finding igtFixCodeLoc address | |
// TutorialMsgDialog should be located first. | |
// go to the address of TutorialMsgDialog | |
// right-click on the first call instruction after the TutorialMsgDialog address and select follow | |
// the instuction that you are taken to is the address you are looking for | |
switch(version){ | |
case "1.02": | |
TutorialMsgDialog = (IntPtr)0x140DC7A8E; | |
CSTutorialDialog = (IntPtr)0x140D6EB98; | |
CSMenuTutorialDialog = (IntPtr)0x140D6D51C; | |
noLogo = (IntPtr)0x140DEBF2B; | |
igtFixEntryPoint = (IntPtr)0x1407A8D19; | |
igtFixCodeLoc = (IntPtr)0x140DBE2D0; | |
vars.igtBasePointer = 0x3B47CF0; | |
break; | |
case "1.03": | |
TutorialMsgDialog = (IntPtr)0x140DC83BE; | |
CSTutorialDialog = (IntPtr)0x140D6F4C8; | |
CSMenuTutorialDialog = (IntPtr)0x140D6DE4C; | |
noLogo = (IntPtr)0x140DEC85B; | |
igtFixEntryPoint = (IntPtr)0x1407A8D99; | |
igtFixCodeLoc = (IntPtr)0x140DBEC00; | |
vars.igtBasePointer = 0x3B48D30; | |
break; | |
case "1.05": | |
TutorialMsgDialog = (IntPtr)0x140DEF53D; | |
CSTutorialDialog = (IntPtr)0x140D94BC8; | |
CSMenuTutorialDialog = (IntPtr)0x140D934BC; | |
noLogo = (IntPtr)0x140E1B1AB; | |
igtFixEntryPoint = (IntPtr)0x1407B1C89; | |
igtFixCodeLoc = (IntPtr)0x140DE5B60; | |
vars.igtBasePointer = 0x3D5AA20; | |
break; | |
case "1.06": | |
TutorialMsgDialog = (IntPtr)0x140DEF8AD; | |
CSTutorialDialog = (IntPtr)0x140D94F38; | |
CSMenuTutorialDialog = (IntPtr)0x140D9382C; | |
noLogo = (IntPtr)0x140E1B51B; | |
igtFixEntryPoint = (IntPtr)0x1407B1C89; | |
igtFixCodeLoc = (IntPtr)0x140DE5ED0; | |
vars.igtBasePointer = 0x3D5AAC0; | |
break; | |
default: | |
throw new NotImplementedException(); | |
break; | |
} | |
//fix detour | |
var igtFixDetourCode = new List<byte>(){0xE9}; | |
int detourTarget = (int) (igtFixCodeLoc.ToInt64()-(igtFixEntryPoint.ToInt64()+5)); | |
igtFixDetourCode.AddRange(BitConverter.GetBytes(detourTarget)); | |
//fix body | |
var frac = game.AllocateMemory(sizeof(double)); | |
var igtFixCode = new List<byte>(){ | |
0x53, //push rbx | |
0x48, 0xBB //mov rbx, fracAddress | |
}; | |
igtFixCode.AddRange(BitConverter.GetBytes((long)frac)); | |
igtFixCode.AddRange(new byte[]{ | |
0x44, 0x0F, 0x10, 0xF0, //movups xmm14, xmm0 | |
0xF3, 0x45, 0x0F, 0x5A, 0xF6, //cvtss2sd xmm14, xmm14 | |
0xF2, 0x49, 0x0F, 0x2C, 0xC6, //cvttsd2si rax, xmm14 | |
0xF2, 0x4C, 0x0F, 0x2A, 0xF8, //cvtsi2sd xmm15, rax | |
0xF2, 0x45, 0x0F, 0x5C, 0xF7, //subsd xmm14, xmm15 | |
0x66, 0x44, 0x0F, 0x10, 0x3B, //movupd xmm15, [rbx] | |
0xF2, 0x45, 0x0F, 0x58, 0xFE, //addsd xmm15, xmm14 | |
0x66, 0x44, 0x0F, 0x11, 0x3B, //movupd [rbx], xmm15 | |
0xF2, 0x49, 0x0F, 0x2C, 0xC7, //cvttsd2si rax, xmm15 | |
0x48, 0x85, 0xC0, //test rax, rax | |
0x74, 0x1D, //jz +1D | |
0x90, 0x90, 0x90, 0x90, //nop | |
0xF2, 0x4C, 0x0F, 0x2A, 0xF0, //cvtsi2sd xmm14, rax | |
0xF2, 0x45, 0x0F, 0x5C, 0xFE, //subsd xmm15, xmm14 | |
0x66, 0x44, 0x0F, 0x11, 0x3B, //movupd [rbx], xmm15 | |
0xF2, 0x45, 0x0F, 0x5A, 0xF6, //cvtsd2ss xmm14, xmm14 | |
0xF3, 0x41, 0x0F, 0x58, 0xC6, //addss xmm0, xmm14 | |
0x45, 0x0F, 0x57, 0xF6, //xorps xmm14, xmm14 | |
0x45, 0x0F, 0x57, 0xFF, //xorps xmm15, xmm15 | |
0x5B, //pop rbx | |
0xF3, 0x48, 0x0F, 0x2C, 0xC0, //cvttss2si rax,xmm0 | |
0xE9 //jmp return igtFixEntryPoint +5 | |
}); | |
int jmpTarget = (int)((igtFixEntryPoint.ToInt64()+5)-(igtFixCodeLoc.ToInt64()+103+5)); | |
igtFixCode.AddRange(BitConverter.GetBytes(jmpTarget)); | |
//Write fixes to game memory | |
game.Suspend(); | |
//No Tutorials | |
vars.game_is_patched = true; | |
vars.game_is_patched &= game.WriteBytes(TutorialMsgDialog,new byte[] {0x75}); | |
vars.game_is_patched &= game.WriteBytes(CSTutorialDialog, new byte[] {0x75}); | |
vars.game_is_patched &= game.WriteBytes(CSMenuTutorialDialog, new byte[] {0x75}); | |
//No logo | |
vars.game_is_patched &= game.WriteBytes(noLogo, new byte[] {0x75}); | |
//No broken timer | |
vars.game_is_patched &= game.WriteBytes(igtFixCodeLoc, igtFixCode.ToArray()); | |
vars.game_is_patched &= game.WriteBytes(igtFixEntryPoint, igtFixDetourCode.ToArray()); | |
game.Resume(); | |
if(vars.game_is_patched != true){ | |
print("Error encountered while writing patch to game memory"); | |
return false; | |
} | |
return true; | |
} | |
update{ | |
//only update if the game was succesfully patched | |
return vars.game_is_patched; | |
} | |
start{ | |
vars.internal_time = 0; | |
vars.isOffsetSet = false; | |
if(settings["Practice"]){ | |
if(old.igt == 0 && current.igt > 0){ | |
vars.isOffsetSet = true; | |
} | |
}else{ | |
//start the timer on new game, next new game cycle, or starting a gauntlet | |
if((old.igt == 0 && current.igt > 0 && current.igt < 1000) || | |
((current.newGameCycle - old.newGameCycle) == 1 && old.skillPoints == current.skillPoints) || | |
(current.newGameCycle == 9999 && vars.ngStartingPosX == System.Math.Round(current.posX, vars.posPrecision) && vars.ngStartingPosZ == System.Math.Round(current.posZ, vars.posPrecision) && vars.ngStartingPosY == System.Math.Round(current.posY, vars.posPrecision)) || | |
((version == "1.05" || version == "1.06") && vars.gauntletStartingPosX.Contains(System.Math.Round(current.posX, vars.posPrecision)) && vars.gauntletStartingPosZ.Contains(System.Math.Round(current.posZ, vars.posPrecision)) && vars.gauntletStartingPosY.Contains(System.Math.Round(current.posY, vars.posPrecision)))){ | |
vars.isOffsetSet = true; | |
} | |
} | |
if(vars.isOffsetSet){ | |
if(current.igt < 1000){ | |
vars.offset_time = 0; | |
}else{ | |
// if the igt is greater than the max igt minus igt to be reduced by | |
if(current.igt > (vars.max_igt - vars.reduced_igt)){ | |
IntPtr igtAddress = (IntPtr)0; | |
new DeepPointer("sekiro.exe", vars.igtBasePointer, new Int32[] { 0x9C }).DerefOffsets(game, out igtAddress); | |
ExtensionMethods.WriteValue<uint>(game, igtAddress, (current.igt - vars.reduced_igt)); | |
vars.offset_time = current.igt - vars.reduced_igt; | |
} | |
else{ | |
vars.offset_time = current.igt; | |
} | |
} | |
} | |
return vars.isOffsetSet; | |
} | |
isLoading{ | |
return true; | |
} | |
gameTime | |
{ | |
// code to handle if timer is started manually | |
if(!vars.isOffsetSet){ | |
if(current.igt < 1000){ | |
vars.offset_time = 0; | |
}else{ | |
// if the igt is greater than the max igt minus igt to be reduced by | |
if(current.igt > (vars.max_igt - vars.reduced_igt)){ | |
IntPtr igtAddress = (IntPtr)0; | |
new DeepPointer("sekiro.exe", vars.igtBasePointer, new Int32[] { 0x9C }).DerefOffsets(game, out igtAddress); | |
ExtensionMethods.WriteValue<uint>(game, igtAddress, (current.igt - vars.reduced_igt)); | |
vars.offset_time = current.igt - vars.reduced_igt; | |
} | |
else{ | |
vars.offset_time = current.igt; | |
} | |
} | |
vars.isOffsetSet = true; | |
} | |
if(/*current.igt < old.igt || */current.igt == 0){ | |
return TimeSpan.FromMilliseconds(vars.internal_time); | |
}else{ | |
vars.internal_time = current.igt - vars.offset_time; | |
return TimeSpan.FromMilliseconds(current.igt - vars.offset_time); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment