Last active
March 1, 2024 11:28
-
-
Save FranklyGD/c2cb3e35a14ba42f4b3890852b86a320 to your computer and use it in GitHub Desktop.
An Auto Splitter script for Spyro the Dragon on select emulators
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
/* | |
Spyro the Dragon (NTSC-U) Autosplitter | |
By: FranklyGD | |
Contributers: lucx40 (lucx1 on Github), ThirstyWraith, goofball564 | |
Special thanks to Laura and lucx40 for helping me test and understand the requirements of | |
the speedruns of this game | |
-- FranklyGD | |
I just gave this autosplitter some updates, enjoy! | |
-- lucx40 | |
Added support for DuckStation, XEBRA and several updated emulator versions. | |
-- ThirstyWraith | |
Added support for more versions of Duckstation (hopefully all of them) and Mednafen :) | |
-- goofball564 | |
If there are any modification requests leave a comment below | |
or join https://discord.gg/spyrospeedrunning and ask me there | |
*/ | |
state ("EmuHawk", "2.6.1") { } | |
state ("EmuHawk", "2.6.2") { } | |
state ("EmuHawk", "2.6.3") { } | |
state ("mednafen", "1.24.1 32bit") { } | |
state ("mednafen", "1.24.1 64bit") { } | |
state ("mednafen", "1.24.2 32bit") { } | |
state ("mednafen", "1.24.2 64bit") { } | |
state ("mednafen", "1.24.3 32bit") { } | |
state ("mednafen", "1.24.3 64bit") { } | |
state ("mednafen", "1.26.1 32bit") { } | |
state ("mednafen", "1.26.1 64bit") { } | |
state ("mednafen", "1.27.1 32bit") { } | |
state ("mednafen", "1.27.1 64bit") { } | |
state ("mednafen", "1.29.0 32bit") { } | |
state ("mednafen", "1.29.0 64bit") { } | |
state ("ePSXe", "1.9.0") { } | |
state ("ePSXe", "1.9.25") { } | |
state ("ePSXe", "2.0.0") { } | |
state ("XEBRA", "210423d") { } | |
state ("duckstation-qt-x64-ReleaseLTCG", "any") { } | |
state ("duckstation-nogui-x64-ReleaseLTCG", "any") { } | |
startup { | |
settings.Add("loadPause", true, "Pause loadless timer when loading levels (does not apply to Real Time)"); | |
settings.Add("dragonPause", true, "Pause loadless timer during dragon loads (does not apply to Real Time)"); | |
settings.Add("titleReset", true, "Reset timer when returning to title screen"); | |
settings.Add("balloonSplit", true, "Split when traveling between homeworlds"); | |
settings.Add("levelSplit", true, "Split when traveling within a homeworld (i.e. returning home and specific level entries defined below)"); | |
settings.Add("gnexusSplit", false, "(Any%) Do not split when returning from Gnorc Cove and Twilight Harbor, split upon entering Gnasty Gnorc"); | |
settings.Add("gnastySplit", true, "(Any%) Split on final hit on Gnasty Gnorc"); | |
settings.Add("lootSplit", false, "(120%) Split when entering Gnasty's Loot"); | |
settings.Add("tucoSplit", false, "(Vortex) Split when Tuco warps you to another world that he suggests to go to"); | |
settings.Add("vortexSplit", false, "(Vortex) Do not split when returning from Dry Canyon or Cliff Town, split upon entering Dr. Shemp"); | |
settings.Add("dragonSplit", false, "(80 Dragons) Split when the freed dragon count reaches 80"); | |
settings.Add("eggSplit", false, "(All Eggs) Split when 12th egg is collected"); //debated split time, this should do though | |
// settings.Add("pinkSplit", false, "(Pink Gem%) Split on final Pink Gem collected by Gnasty Gnorc"); | |
settings.Add("balloonStart", false, "(Homeworld Practice) Start timer when travelling between homeworlds"); | |
// Duckstation Vars | |
vars.duckstationProcessNames = new List<string> { | |
"duckstation-qt-x64-ReleaseLTCG", | |
"duckstation-nogui-x64-ReleaseLTCG", | |
}; | |
vars.duckstation = false; | |
vars.duckstationBaseRAMAddressFound = false; | |
vars.duckstationStopwatch = new Stopwatch(); | |
vars.DUCKSTATION_ADDRESS_SEARCH_INTERVAL = 1000; | |
vars.baseRAMAddress = IntPtr.Zero; | |
vars.homeworldIDs = new List<uint> { | |
0x0a, // Artisans | |
0x14, // Peace Keepers | |
0x1e, // Magic Crafters | |
0x28, // Beast Makers | |
0x32, // Dream Weavers | |
0x3c, // Gnorc Gnexus | |
}; | |
vars.tucoSuggestionIDs = new List<uint> { | |
// 0x0b, // Stone Hill (Unused in any categories) | |
0x15, // Dry Canyon | |
0x16, // Cliff Town | |
}; | |
vars.gnexusIDs = new List<uint> { | |
0x3d, // Gnorc Cove | |
0x3e, // Twilight Harbor | |
}; | |
//Predefining those variables seems completely unnecessary yet for some reason things break in the weirdest ways possible | |
//if I don't, so this stays :) | |
vars.dragonInRAM = false; | |
vars.anAdventureBegan = false; | |
// vars.gnastyFinalPinkGems = 0; | |
// vars.killedGnasty = false; //I use this for dirty implementation of Pink Gem | |
} | |
init { | |
refreshRate = 30; | |
var mainModule = modules.First(); | |
switch (mainModule.ModuleMemorySize) { | |
// Bizhawk | |
case 0x456000: | |
version = "2.6.1"; | |
vars.baseRAMAddress = modules.Where(x => x.ModuleName == "octoshock.dll").First().BaseAddress + 0x310f80; | |
break; | |
case 0x454000: | |
version = "2.6.2"; | |
vars.baseRAMAddress = modules.Where(x => x.ModuleName == "octoshock.dll").First().BaseAddress + 0x30df80; | |
break; | |
case 0x45a000: | |
version = "2.6.3"; | |
vars.baseRAMAddress = modules.Where(x => x.ModuleName == "octoshock.dll").First().BaseAddress + 0x30df80; | |
break; | |
// Mednafen | |
case 0x42c9000: | |
version = "1.24.1 32bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x1c96560; | |
break; | |
case 0x5eef000: | |
version = "1.24.1 64bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x25bf280; | |
break; | |
case 0x42c6000: | |
version = "1.24.2 32bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x1c93560; | |
break; | |
case 0x5eec000: | |
version = "1.24.2/1.24.3 64bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x25bc280; | |
break; | |
case 0x42c7000: | |
version = "1.24.3/1.26.1 32bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x1c94560; | |
break; | |
case 0x5e83000: | |
version = "1.26.1 64 bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x2553280; | |
break; | |
case 0x3a44000: | |
version = "1.27.1 32bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x13ff160; | |
break; | |
case 0x55f1000: | |
version = "1.27.1 64bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x1cade80; | |
break; | |
case 0x3C81000: | |
version = "1.29.0 32bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x1438160; | |
break; | |
case 0x574B000: | |
version = "1.29.0 64bit"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x1C03E80; | |
break; | |
// ePSXe | |
case 0x9d3000: | |
version = "1.9.0"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x6579A0; | |
break; | |
case 0xa08000: | |
version = "1.9.25"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x68b6a0; | |
break; | |
case 0x1359000: | |
version = "2.0.0"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x81a020; | |
break; | |
// XEBRA | |
case 0xbd000: | |
version = "210423d"; | |
vars.baseRAMAddress = mainModule.BaseAddress + 0x3110000; | |
break; | |
// DuckStation or unsupported | |
default: | |
break; | |
} | |
// Unfortunately, duckstation doesn't have a static base RAM address, | |
// so we'll have to keep track of it in the update block. | |
if (vars.duckstationProcessNames.Contains(game.ProcessName)) { | |
vars.duckstation = true; | |
version = "any"; | |
vars.baseRAMAddress = IntPtr.Zero; | |
} | |
} | |
update { | |
if (version == "") { | |
return false; | |
} | |
if (vars.duckstation) { | |
// Find base RAM address in Duckstation by searching its memory pages. | |
// Do this periodically (using stopwatch to determine when to search again) | |
// instead of every update to reduce unnecessary computation. | |
if (!vars.duckstationBaseRAMAddressFound) { | |
if (!vars.duckstationStopwatch.IsRunning || vars.duckstationStopwatch.ElapsedMilliseconds > vars.DUCKSTATION_ADDRESS_SEARCH_INTERVAL) { | |
vars.duckstationStopwatch.Start(); | |
vars.baseRAMAddress = game.MemoryPages(true).Where(p => p.Type == MemPageType.MEM_MAPPED && p.RegionSize == (UIntPtr)0x200000).FirstOrDefault().BaseAddress; | |
if (vars.baseRAMAddress == IntPtr.Zero) { | |
vars.duckstationStopwatch.Restart(); | |
return false; | |
} | |
else { | |
vars.duckstationStopwatch.Reset(); | |
vars.duckstationBaseRAMAddressFound = true; | |
} | |
} | |
else { | |
return false; | |
} | |
} | |
// Verify base RAM address is still valid on each update | |
IntPtr temp1 = vars.baseRAMAddress; | |
IntPtr temp2 = IntPtr.Zero; | |
if (!game.ReadPointer(temp1, out temp2)) { | |
vars.duckstationBaseRAMAddressFound = false; | |
vars.baseRAMAddress = IntPtr.Zero; | |
return false; | |
} | |
} | |
// Address assignment has been moved to update block to support Duckstation's | |
// changing base RAM address. The performance impact of this should | |
// be negligible for non-Duckstation users, | |
// and it reduces code complexity to have it once here. | |
// States | |
vars.gameStateAddress = vars.baseRAMAddress + 0x757d8; | |
vars.loadStateAddress = vars.baseRAMAddress + 0x75864; | |
vars.dragonStateAddress = vars.baseRAMAddress + 0x77058; | |
vars.finalBossStateAddress = vars.baseRAMAddress + 0x160f08; | |
// Counters | |
vars.gemCountAddress = vars.baseRAMAddress + 0x75860; | |
vars.dragonCountAddress = vars.baseRAMAddress + 0x75750; | |
vars.eggCountAddress = vars.baseRAMAddress + 0x75810; | |
vars.eggVisualAddress = vars.baseRAMAddress + 0x77FD4; | |
// Etc | |
vars.controlLockAddress = vars.baseRAMAddress + 0x78c48; | |
vars.worldIDAddress = vars.baseRAMAddress + 0x7596c; | |
vars.nextSuggestedWorldIDAddress = vars.baseRAMAddress + 0x758b4; | |
// Read memory | |
current.gameState = memory.ReadValue<uint>((IntPtr)vars.gameStateAddress); | |
current.loadState = memory.ReadValue<int>((IntPtr)vars.loadStateAddress); | |
current.dragonState = memory.ReadValue<uint>((IntPtr)vars.dragonStateAddress); | |
current.gemCount = memory.ReadValue<uint>((IntPtr)vars.gemCountAddress); | |
current.dragonCount = memory.ReadValue<uint>((IntPtr)vars.dragonCountAddress); | |
current.eggCount = memory.ReadValue<uint>((IntPtr)vars.eggCountAddress); | |
current.eggVisual = memory.ReadValue<uint>((IntPtr)vars.eggVisualAddress); | |
current.controlLock = memory.ReadValue<uint>((IntPtr)vars.controlLockAddress); | |
current.worldID = memory.ReadValue<uint>((IntPtr)vars.worldIDAddress); | |
current.nextSuggestedWorldID = memory.ReadValue<uint>((IntPtr)vars.nextSuggestedWorldIDAddress); | |
current.finalBossState = memory.ReadValue<byte>((IntPtr)vars.finalBossStateAddress); | |
} | |
start { | |
// Check to start timer if the game has given control to the player or has been paused immediately | |
// Also should only happen if coming from the title screen | |
if (current.gameState == 0x0d) { | |
vars.anAdventureBegan = true; | |
} | |
if (vars.anAdventureBegan && old.controlLock != 0 && (current.gameState == 0x03 || current.gameState == 0x02)) { | |
vars.anAdventureBegan = false; | |
return true; | |
} | |
// Check if the controls are no longer locked at the beginning of the game | |
if (vars.anAdventureBegan && current.gameState == 0x00 && old.controlLock != 0 && current.controlLock == 0) { | |
vars.anAdventureBegan = false; | |
return true; | |
} | |
if (settings["balloonStart"]) { | |
// Start timer when travelling between homeworlds if said option is checked | |
if (old.worldID != current.worldID && vars.homeworldIDs.Contains(old.worldID) && vars.homeworldIDs.Contains(current.worldID)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
reset { | |
// Reset when the game is currently on the main menu | |
return settings["titleReset"] && current.gameState == 0x0d; | |
} | |
split { | |
// On world changed... | |
if (old.worldID != current.worldID && ( | |
// If the option is enabled, check if changing between homeworlds | |
settings["balloonSplit"] && vars.homeworldIDs.Contains(old.worldID) && vars.homeworldIDs.Contains(current.worldID) || | |
// If the option is enabled, check if changing directly to the specified worlds from Tuco (not entering worlds from Peace Keepers) | |
settings["tucoSplit"] && old.worldID != 0x14 && vars.tucoSuggestionIDs.Contains(current.worldID) || | |
settings["levelSplit"] && ( | |
// Check if leaving a world and returning to homeworld, also make sure it was not done via gameover | |
// Ignore returning from Tuco's suggested worlds (Dry/Cliffs), Gnorc Cove, Twilight Harbor and Gnasty Gnorc | |
!vars.homeworldIDs.Contains(old.worldID) && vars.homeworldIDs.Contains(current.worldID) && current.gameState != 5 && !vars.tucoSuggestionIDs.Contains(old.worldID) && !vars.gnexusIDs.Contains(old.worldID) && old.worldID != 0x3f || | |
// If the option is disabled, split when returning from Tuco's suggested worlds to Peace Keepers | |
!settings["vortexSplit"] && vars.tucoSuggestionIDs.Contains(old.worldID) && current.worldID == 0x14 && current.gameState != 5 || | |
// If the option is enabled, split upon entering Shemp portal | |
settings["vortexSplit"] && current.worldID == 0x18 || | |
// If the option is disabled, split when returning from Gnorc Cove or Twilight Harbor to Gnorc Gnexus | |
!settings["gnexusSplit"] && vars.gnexusIDs.Contains(old.worldID) && current.worldID == 0x3c && current.gameState != 5 || | |
// If the option is enabled, split upon entering Gnasty Gnorc | |
settings["gnexusSplit"] && current.worldID == 0x3f || | |
// If the option is enabled, split upon entering Gnasty's Loot portal | |
settings["lootSplit"] && current.worldID == 0x40 | |
) | |
)) { | |
return true; | |
} | |
// "Any%" category conditions if the game is in the final boss world, check Gnasty's state if he just died | |
if (current.worldID == 0x3f && old.finalBossState != 0x08 && current.finalBossState == 0x08) { | |
// if (settings["pinkSplit"]) { | |
// vars.killedGnasty = true; | |
// } | |
if (settings["gnastySplit"]) { | |
return true; | |
} | |
} | |
//Appreciate my Pink Gems ok | |
// if (vars.killedGnasty && old.gemCount == (current.gemCount - 25)) { | |
// vars.gnastyFinalPinkGems = vars.gnastyFinalPinkGems + 1; | |
// if (vars.gnastyFinalPinkGems == 4) { | |
// vars.killedGnasty = false; | |
// vars.gnastyFinalPinkGems = 0; | |
// return true; | |
// } | |
// } | |
// "80 Dragons" category conditions when the visual number flips to 80 | |
if (settings["dragonSplit"] && current.dragonCount == 80 && old.dragonState == 5 && current.dragonState == 6) { | |
vars.dragonFrame = timer.CurrentTime.RealTime.Value.TotalMilliseconds; | |
vars.dragonInRAM = true; | |
} | |
if (vars.dragonInRAM && timer.CurrentTime.RealTime.Value.TotalMilliseconds > vars.dragonFrame + 533) { | |
vars.dragonInRAM = false; | |
return true; | |
} | |
// If in Gnasty's Loot with 120% progress and the screen turns completely black | |
// Doesn't require an option since this only triggers for 120% runs | |
if (current.worldID == 0x40 && current.gemCount == 14000 && current.dragonCount == 80 && current.eggCount == 12 && old.gameState != 14 && current.gameState == 14) { | |
return true; | |
} | |
// Egg is popular now so... | |
// Commented code is unreliable so using another RAM address that is close enough (also final timing seems debated anyway) | |
if (settings["eggSplit"] && current.eggVisual == 0x0c && old.eggVisual == 0x0b) { | |
return true; | |
} | |
return false; | |
} | |
isLoading { | |
return | |
// If the option is enabled, pause timer when the loading state is "idle" or not "active"; pause timer also when game is loading during a Game Over - This only happen on tranfer back to the homeworld. Also, do not pause timer during Any% or 120% Cutscene | |
settings["loadPause"] && current.loadState != -1 && current.loadState != 11 && current.gameState != 14 || | |
// If the option is enabled, at specific stages of the the dragon statue cutscene pause the timer | |
settings["dragonPause"] && ( | |
current.dragonState > 1 && current.dragonState < 4 || current.dragonState > 4 && current.dragonState != 7 | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment