diff --git a/docs/game_data/lua_enums.txt b/docs/game_data/lua_enums.txt
index ac9775995..76d53d635 100644
--- a/docs/game_data/lua_enums.txt
+++ b/docs/game_data/lua_enums.txt
@@ -1548,6 +1548,7 @@ ENT_TYPE = {
POWERUPCAPABLE = 1250,
PROTOSHOPKEEPER = 1251,
PUNISHBALL = 1252,
+ PURCHASABLE = 1334,
PUSHBLOCK = 1253,
QILIN = 1254,
QUICKSAND = 1255,
@@ -2118,10 +2119,9 @@ ON = {
ARENA_SCORE = 27,
ARENA_SELECT = 24,
ARENA_STAGES = 22,
- BLOCKED_GAME_LOOP = 159,
- BLOCKED_LEVEL_GENERATION = 157,
- BLOCKED_PROCESS_INPUT = 160,
- BLOCKED_UPDATE = 158,
+ BLOCKED_GAME_LOOP = 162,
+ BLOCKED_PROCESS_INPUT = 163,
+ BLOCKED_UPDATE = 161,
CAMP = 11,
CHARACTER_SELECT = 9,
CONSTELLATION = 19,
@@ -2150,8 +2150,10 @@ ON = {
POST_LEVEL_GENERATION = 112,
POST_LOAD_JOURNAL_CHAPTER = 139,
POST_LOAD_SCREEN = 136,
+ POST_LOAD_STATE = 160,
POST_PROCESS_INPUT = 154,
POST_ROOM_GENERATION = 111,
+ POST_SAVE_STATE = 158,
POST_UPDATE = 143,
PRE_GAME_LOOP = 155,
PRE_GET_FEAT = 140,
@@ -2165,7 +2167,9 @@ ON = {
PRE_LOAD_JOURNAL_CHAPTER = 138,
PRE_LOAD_LEVEL_FILES = 109,
PRE_LOAD_SCREEN = 135,
+ PRE_LOAD_STATE = 159,
PRE_PROCESS_INPUT = 153,
+ PRE_SAVE_STATE = 157,
PRE_SET_FEAT = 141,
PRE_UPDATE = 142,
PROLOGUE = 2,
diff --git a/docs/game_data/spel2.lua b/docs/game_data/spel2.lua
index 120d2f8d0..5d37200cc 100644
--- a/docs/game_data/spel2.lua
+++ b/docs/game_data/spel2.lua
@@ -1378,7 +1378,15 @@ function play_adventure() end
---@param seed integer?
---@return nil
function play_seeded(seed) end
----Save current level state to slot 1..4. These save states are invalid after you exit the level, but can be used to rollback to an earlier state in the same level. You probably definitely shouldn't use save state functions during an update, and sync them to the same event outside an update (i.e. GUIFRAME, POST_UPDATE).
+---@return boolean
+function toast_visible() end
+---@return boolean
+function speechbubble_visible() end
+---@return nil
+function cancel_toast() end
+---@return nil
+function cancel_speechbubble() end
+---Save current level state to slot 1..4. These save states are invalid and cleared after you exit the current level, but can be used to rollback to an earlier state in the same level. You probably definitely shouldn't use save state functions during an update, and sync them to the same event outside an update (i.e. GUIFRAME, POST_UPDATE). These slots are already allocated by the game, actually used for online rollback, and use no additional memory. Also see SaveState if you need more.
---@param slot integer
---@return nil
function save_state(slot) end
@@ -1386,18 +1394,14 @@ function save_state(slot) end
---@param slot integer
---@return nil
function load_state(slot) end
+---Clear save state from slot 1..4.
+---@param slot integer
+---@return nil
+function clear_state(slot) end
---Get StateMemory from a save_state slot.
---@param slot integer
---@return StateMemory
function get_save_state(slot) end
----@return boolean
-function toast_visible() end
----@return boolean
-function speechbubble_visible() end
----@return nil
-function cancel_toast() end
----@return nil
-function cancel_speechbubble() end
---Returns RawInput, a game structure for raw keyboard and controller state
---@return RawInput
function get_raw_input() end
@@ -2257,6 +2261,12 @@ do
---@field room_index integer
---@field owner_uid integer
+---@class SaveState
+ ---@field load fun(self): nil @Load a SaveState
+ ---@field save fun(self): nil @Save over a previously allocated SaveState
+ ---@field clear fun(self): nil @Delete the SaveState and free the memory. The SaveState can't be used after this.
+ ---@field get_state fun(self): StateMemory @Access the StateMemory inside a SaveState
+
---@class BackgroundMusic
---@field game_startup BackgroundSound
---@field main_backgroundtrack BackgroundSound
@@ -6471,6 +6481,11 @@ function Color:fuchsia() end
function Color:purple() end
--## Constructors
+
+SaveState = nil
+---Create a new temporary SaveState/clone of the main level state. Unlike save_state slots that are preallocated by the game anyway, these will use 32MiB a pop and aren't freed automatically, so make sure to clear them or reuse the same one to save memory. The garbage collector will eventually clear the SaveStates you don't have a handle to any more though.
+---@return SaveState
+function SaveState:new() end
---Create a new color - defaults to black
---@return Color
function Color:new() end
@@ -8171,6 +8186,7 @@ ENT_TYPE = {
POWERUPCAPABLE = 1250,
PROTOSHOPKEEPER = 1251,
PUNISHBALL = 1252,
+ PURCHASABLE = 1334,
PUSHBLOCK = 1253,
QILIN = 1254,
QUICKSAND = 1255,
@@ -8764,10 +8780,9 @@ ON = {
ARENA_SCORE = 27,
ARENA_SELECT = 24,
ARENA_STAGES = 22,
- BLOCKED_GAME_LOOP = 159,
- BLOCKED_LEVEL_GENERATION = 157,
- BLOCKED_PROCESS_INPUT = 160,
- BLOCKED_UPDATE = 158,
+ BLOCKED_GAME_LOOP = 162,
+ BLOCKED_PROCESS_INPUT = 163,
+ BLOCKED_UPDATE = 161,
CAMP = 11,
CHARACTER_SELECT = 9,
CONSTELLATION = 19,
@@ -8796,8 +8811,10 @@ ON = {
POST_LEVEL_GENERATION = 112,
POST_LOAD_JOURNAL_CHAPTER = 139,
POST_LOAD_SCREEN = 136,
+ POST_LOAD_STATE = 160,
POST_PROCESS_INPUT = 154,
POST_ROOM_GENERATION = 111,
+ POST_SAVE_STATE = 158,
POST_UPDATE = 143,
PRE_GAME_LOOP = 155,
PRE_GET_FEAT = 140,
@@ -8811,7 +8828,9 @@ ON = {
PRE_LOAD_JOURNAL_CHAPTER = 138,
PRE_LOAD_LEVEL_FILES = 109,
PRE_LOAD_SCREEN = 135,
+ PRE_LOAD_STATE = 159,
PRE_PROCESS_INPUT = 153,
+ PRE_SAVE_STATE = 157,
PRE_SET_FEAT = 141,
PRE_UPDATE = 142,
PROLOGUE = 2,
diff --git a/docs/game_data/vtable_sizes.csv b/docs/game_data/vtable_sizes.csv
index 5e96fadbd..701f60c50 100644
--- a/docs/game_data/vtable_sizes.csv
+++ b/docs/game_data/vtable_sizes.csv
@@ -4,7 +4,7 @@ TypeID,Name,vtable offset,~Entity,create_rendering_info,handle_state_machine,kil
3,ENT_TYPE_FLOOR_BORDERTILE_OCTOPUS,8786,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
4,ENT_TYPE_FLOOR_GENERIC,8786,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
5,ENT_TYPE_FLOOR_SURFACE,8786,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
-6,ENT_TYPE_FLOOR_SURFACE_HIDDEN,48206,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,ret,0x2287cbd0,0x2287d100,ret 0,ret true,ret,ret,ret,ret,0x228bff80,0x2287d100,ret 0,0x228bfe80,ret,ret,0x228bfe90,0x2292e2c0,0x228bdfb0,ret true,ret 0,ret 0,ret,ret,0x228ee610,0x22997220,0x228bdfb0,0x228c4340,ret 0,ret 0,0x22999730,0x22999750,0x22999aa0,0x228c6ab0,0x22999f90,ret true,ret 0,ret 0,0x22999fc0,ret,0x2299a010,0x228c6ab0,0x228bdfb0,0x2289e280,0x2299bf40,0x2299d580,0x2299d8a0,0x229a3840,0x229a3b00,0xffff817dfdb00173,0xffff817efdb00000,0x228a9670
+6,ENT_TYPE_FLOOR_SURFACE_HIDDEN,48206,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,ret,0x2287cbd0,0x2287d100,ret 0,ret true,ret,ret,ret,ret,0x228bff80,0x2287d100,ret 0,0x228bfe80,ret,ret,0x228bfe90,0x2292e2c0,0x228bdfb0,ret true,ret 0,ret 0,ret,ret,0x228ee610,0x22997220,0x228bdfb0,0x228c4340,ret 0,ret 0,0x22999730,0x22999750,0x22999aa0,0x228c6ab0,0x22999f90,ret true,ret 0,ret 0,0x22999fc0,ret,0x2299a010,0x228c6ab0,0x228bdfb0,0x2289e280,0x2299bf40,0x2299d580,0x2299d8a0,0x229a3840,0x229a3b00,0xffff817ed3580173,0xffff817fd3580000,0x228a9670
7,ENT_TYPE_FLOOR_BASECAMP_SINGLEBED,10626,0x2287aed0,0x227fcd90,ret,0x2297d3f0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x2297d600,0x2297d680,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
8,ENT_TYPE_FLOOR_BASECAMP_DININGTABLE,10626,0x2287aed0,0x227fcd90,ret,0x2297d3f0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x2297d600,0x2297d680,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
9,ENT_TYPE_FLOOR_BASECAMP_LONGTABLE,10626,0x2287aed0,0x227fcd90,ret,0x2297d3f0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x2297d600,0x2297d680,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
@@ -91,7 +91,7 @@ TypeID,Name,vtable offset,~Entity,create_rendering_info,handle_state_machine,kil
90,ENT_TYPE_FLOOR_FORCEFIELD_TOP,8786,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
91,ENT_TYPE_FLOOR_HORIZONTAL_FORCEFIELD,8946,0x2287aed0,0x227fcd90,0x22974270,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x22974960,0x227fd790,0x227fd930,0x227fd950,0x22974760,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22974240,END OF ENTITY,0x22928d00,ret,0x2287cbd0
92,ENT_TYPE_FLOOR_HORIZONTAL_FORCEFIELD_TOP,8786,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
-93,ENT_TYPE_FLOOR_PEN,48142,0x2287aed0,0x227fcd90,ret,0x22978910,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x22978990,0x909801502b6000c,0x6801100b3030c,0x68010fdba0009,0x78010fdba0009,0x98012fdb80008,0x98012fdba000a,0xa8012fdbb000b,0xbf7f8008fdbb000b,0xffff80093d300000
+93,ENT_TYPE_FLOOR_PEN,48142,0x2287aed0,0x227fcd90,ret,0x22978910,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x22978990,0x9098015d85e000c,0x68011d65b030c,0x68011d3620009,0x78011d3620009,0x98013d3600008,0x98013d362000a,0xa8013d363000b,0xbf7f8009d363000b,0xffff800a12d80000
94,ENT_TYPE_FLOOR_TOMB,8906,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x2298cc30,END OF ENTITY,0x22928d00,0x2292c410,0x2288b9e0
95,ENT_TYPE_FLOOR_YAMA_PLATFORM,8866,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x2288d9c0,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2287cbd0
96,ENT_TYPE_FLOOR_EMPRESS_GRAVE,8826,0x2287aed0,0x227fcd90,ret,0x22926290,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x22927b90,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,0x22927f40,ret,ret,0x22928420,ret,ret,0x227fe2f0,0x22928bd0,END OF ENTITY,0x22928d00,0x2292c410,0x2288b9e0
@@ -811,7 +811,7 @@ TypeID,Name,vtable offset,~Entity,create_rendering_info,handle_state_machine,kil
841,ENT_TYPE_BG_EGGSAC_STAINS,2460,0x2287aed0,0x227fcd90,ret,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,ret,END OF ENTITY
844,ENT_TYPE_LOGICAL_CONSTELLATION,2840,0x2287aed0,0x227fcd90,ret,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,0x229f53f0,END OF ENTITY
845,ENT_TYPE_LOGICAL_SHOOTING_STARS_SPAWNER,2802,0x2287aed0,0x227fcd90,0x22a004c0,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,0x22a004b0,END OF ENTITY
-846,ENT_TYPE_LOGICAL_DOOR,48710,0x2287aed0,0x227fcd90,0x229f8090,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,ret,END OF ENTITY,0xffff80093d300000,0x403f80093db00000
+846,ENT_TYPE_LOGICAL_DOOR,48710,0x2287aed0,0x227fcd90,0x229f8090,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,ret,END OF ENTITY,0xffff800a12d80000,0x403f800a13580000
847,ENT_TYPE_LOGICAL_DOOR_AMBIENT_SOUND,2764,0x2287aed0,0x227fcd90,0x229f83c0,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,0x229f8360,END OF ENTITY
848,ENT_TYPE_LOGICAL_BLACKMARKET_DOOR,2726,0x2287aed0,0x227fcd90,0x229f8090,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,ret,ret 0,ret,ret,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,0x229f3af0,END OF ENTITY
849,ENT_TYPE_LOGICAL_ARROW_TRAP_TRIGGER,48672,0x2287aed0,0x227fcd90,0x229f35f0,0x227fcfd0,ret,0x227fd000,0x227fd0b0,0x227fd110,0x227fd150,0x227fd570,0x227fd580,ret 0,0x227fd5a0,0x227fd730,0x227fd740,0x227fd750,0x227fd770,0x227fd790,0x227fd930,0x227fd950,0x227fd960,0x227fe160,ret 0,0x229f3a20,ret 0,ret,0x229f3a40,ret 0,ret,ret,ret,ret,ret,ret,ret,0x227fe2f0,ret,END OF ENTITY
diff --git a/docs/parse_source.py b/docs/parse_source.py
index 1e08a1b3d..d99891d15 100644
--- a/docs/parse_source.py
+++ b/docs/parse_source.py
@@ -117,6 +117,7 @@
"../src/game_api/search.hpp",
"../src/game_api/bucket.hpp",
"../src/game_api/socket.hpp",
+ "../src/game_api/savestate.hpp",
]
api_files = [
"../src/game_api/script/script_impl.cpp",
@@ -685,7 +686,7 @@ def run_parse():
var_name = var[0]
cpp = var[1]
-
+
if var[1].startswith("sol::property"):
param_match = re.match(
rf"sol::property\(\[\]\({underlying_cpp_type['name']}&(\w+)\)",
diff --git a/docs/src/includes/_enums.md b/docs/src/includes/_enums.md
index 3215971b2..254f8aed1 100644
--- a/docs/src/includes/_enums.md
+++ b/docs/src/includes/_enums.md
@@ -889,6 +889,10 @@ Name | Data | Description
[POST_PROCESS_INPUT](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.POST_PROCESS_INPUT) | ON::POST_PROCESS_INPUT | Runs right after the game gets input from various devices and writes to a bunch of buttons-variables. Probably the first chance you have to capture or edit buttons_gameplay or buttons_menu sort of things.
[PRE_GAME_LOOP](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.PRE_GAME_LOOP) | ON::PRE_GAME_LOOP | Runs right before the main engine loop. Return true to block state updates and menu updates, i.e. to pause inside menus.
[POST_GAME_LOOP](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.POST_GAME_LOOP) | ON::POST_GAME_LOOP | Runs right after the main engine loop.
+[PRE_SAVE_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.PRE_SAVE_STATE) | ON::PRE_SAVE_STATE | Runs right before the main [StateMemory](#StateMemory) is manually saved to a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState). Return true to block save.
Params: int slot, [StateMemory](#StateMemory) saved
+[POST_SAVE_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.POST_SAVE_STATE) | ON::POST_SAVE_STATE | Runs right after the main [StateMemory](#StateMemory) is manually saved to a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState).
Params: int slot, [StateMemory](#StateMemory) saved
+[PRE_LOAD_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.PRE_LOAD_STATE) | ON::PRE_LOAD_STATE | Runs right before the main [StateMemory](#StateMemory) is manually loaded from a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState). Return true to block load.
Params: int slot, [StateMemory](#StateMemory) loaded
+[POST_LOAD_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.POST_LOAD_STATE) | ON::POST_LOAD_STATE | Runs right after the main [StateMemory](#StateMemory) is manually loaded from a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState).
Params: int slot, [StateMemory](#StateMemory) loaded
[BLOCKED_UPDATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.BLOCKED_UPDATE) | ON::BLOCKED_UPDATE | Runs instead of POST_UPDATE when anything blocks a PRE_UPDATE. Even runs in Playlunky when [Overlunky](#Overlunky) blocks a PRE_UPDATE.
[BLOCKED_GAME_LOOP](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.BLOCKED_GAME_LOOP) | ON::BLOCKED_GAME_LOOP | Runs instead of POST_GAME_LOOP when anything blocks a PRE_GAME_LOOP. Even runs in Playlunky when [Overlunky](#Overlunky) blocks a PRE_GAME_LOOP.
[BLOCKED_PROCESS_INPUT](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.BLOCKED_PROCESS_INPUT) | ON::BLOCKED_PROCESS_INPUT | Runs instead of POST_PROCESS_INPUT when anything blocks a PRE_PROCESS_INPUT. Even runs in Playlunky when [Overlunky](#Overlunky) blocks a PRE_PROCESS_INPUT.
diff --git a/docs/src/includes/_events.md b/docs/src/includes/_events.md
index b2565057a..d03a84b5c 100644
--- a/docs/src/includes/_events.md
+++ b/docs/src/includes/_events.md
@@ -634,6 +634,34 @@ Runs right before the main engine loop. Return true to block state updates and m
Runs right after the main engine loop.
+## ON.PRE_SAVE_STATE
+
+
+> Search script examples for [ON.PRE_SAVE_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.PRE_SAVE_STATE)
+
+Runs right before the main [StateMemory](#StateMemory) is manually saved to a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState). Return true to block save.
Params: int slot, [StateMemory](#StateMemory) saved
+
+## ON.POST_SAVE_STATE
+
+
+> Search script examples for [ON.POST_SAVE_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.POST_SAVE_STATE)
+
+Runs right after the main [StateMemory](#StateMemory) is manually saved to a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState).
Params: int slot, [StateMemory](#StateMemory) saved
+
+## ON.PRE_LOAD_STATE
+
+
+> Search script examples for [ON.PRE_LOAD_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.PRE_LOAD_STATE)
+
+Runs right before the main [StateMemory](#StateMemory) is manually loaded from a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState). Return true to block load.
Params: int slot, [StateMemory](#StateMemory) loaded
+
+## ON.POST_LOAD_STATE
+
+
+> Search script examples for [ON.POST_LOAD_STATE](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=ON.POST_LOAD_STATE)
+
+Runs right after the main [StateMemory](#StateMemory) is manually loaded from a slot or a custom [SaveState](#SaveState). Slot is 1..4 or -1 on custom [SaveState](#SaveState).
Params: int slot, [StateMemory](#StateMemory) loaded
+
## ON.BLOCKED_UPDATE
diff --git a/docs/src/includes/_globals.md b/docs/src/includes/_globals.md
index 272fc1266..820138887 100644
--- a/docs/src/includes/_globals.md
+++ b/docs/src/includes/_globals.md
@@ -1258,6 +1258,15 @@ Change the amount of frames after the damage from poison is applied
Clear cache for a file path or the whole directory
+### clear_state
+
+
+> Search script examples for [clear_state](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=clear_state)
+
+#### nil clear_state(int slot)
+
+Clear save state from slot 1..4.
+
### clr_mask
@@ -1787,7 +1796,7 @@ Runs the [ON](#ON).SAVE callback. Fails and returns false, if you're trying to s
#### nil save_state(int slot)
-Save current level state to slot 1..4. These save states are invalid after you exit the level, but can be used to rollback to an earlier state in the same level. You probably definitely shouldn't use save state functions during an update, and sync them to the same event outside an update (i.e. GUIFRAME, POST_UPDATE).
+Save current level state to slot 1..4. These save states are invalid and cleared after you exit the current level, but can be used to rollback to an earlier state in the same level. You probably definitely shouldn't use save state functions during an update, and sync them to the same event outside an update (i.e. GUIFRAME, POST_UPDATE). These slots are already allocated by the game, actually used for online rollback, and use no additional memory. Also see [SaveState](#SaveState) if you need more.
### script_enabled
diff --git a/docs/src/includes/_types.md b/docs/src/includes/_types.md
index 6f9b0877e..f8134cf3e 100644
--- a/docs/src/includes/_types.md
+++ b/docs/src/includes/_types.md
@@ -956,6 +956,17 @@ Type | Name | Description
map<int, [ItemOwnerDetails](#ItemOwnerDetails)> | [owned_items](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=owned_items) | key/index is the uid of an item
vector<[RoomOwnerDetails](#RoomOwnerDetails)> | [owned_rooms](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=owned_rooms) |
+### SaveState
+
+
+Type | Name | Description
+---- | ---- | -----------
+[SaveState](#SaveState) | [new()](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=SaveState) | Create a new temporary [SaveState](#SaveState)/clone of the main level state. Unlike save_state slots that are preallocated by the game anyway, these will use 32MiB a pop and aren't freed automatically, so make sure to clear them or reuse the same one to save memory. The garbage collector will eventually clear the SaveStates you don't have a handle to any more though.
+nil | [load()](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=load) | Load a [SaveState](#SaveState)
+nil | [save()](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=save) | Save over a previously allocated [SaveState](#SaveState)
+nil | [clear()](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=clear) | Delete the [SaveState](#SaveState) and free the memory. The [SaveState](#SaveState) can't be used after this.
+[StateMemory](#StateMemory) | [get_state()](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=get_state) | Access the [StateMemory](#StateMemory) inside a [SaveState](#SaveState)
+
### ShortTileCodeDef
Used in [get_short_tile_code](#get_short_tile_code), [get_short_tile_code_definition](#get_short_tile_code_definition) and [PostRoomGenerationContext](#PostRoomGenerationContext)
diff --git a/examples/savestate.lua b/examples/savestate.lua
new file mode 100644
index 000000000..f9f1ae8ab
--- /dev/null
+++ b/examples/savestate.lua
@@ -0,0 +1,91 @@
+meta.name = "Teleport predictor and SaveStates"
+meta.author = "Dregu"
+meta.version = "1.0"
+
+states = {}
+
+function clear_states()
+ for _, v in pairs(states) do
+ v:clear()
+ end
+ states = {}
+ if tp then
+ tp:clear()
+ tp = nil
+ end
+end
+
+set_callback(function(ctx)
+ ctx:window("Advanced SaveStates", 0, 0, 0, 0, true, function(ctx)
+ if ctx:win_button("New save") then
+ states[#states + 1] = SaveState:new()
+ end
+ ctx:win_inline()
+ if ctx:win_button("Clear saves") then
+ clear_states()
+ end
+ ctx:win_separator()
+ for i, v in pairs(states) do
+ if ctx:win_button(F "Load #{i}: frame {v:get_state().time_level}") then
+ v:load()
+ end
+ end
+ end)
+
+ -- teleport predictor/simulator using save states
+ -- simulates accurate teleport destination when pressing Z or X while paused
+ if not players[1] then return end
+ if (get_io().keypressed(KEY.Z) or get_io().keypressed(KEY.X)) and pause:paused() then
+ if tp then
+ tp:save()
+ else
+ tp = SaveState:new()
+ end
+ box_frame = state.time_level
+
+ players[1]:topmost_mount():set_post_update_state_machine(function(e)
+ clear_callback()
+ box = get_hitbox(e.uid)
+ box_color = 0xcc33ff33
+ end)
+ set_callback(function()
+ clear_callback()
+ if not players[1] then box_color = 0xcc3333ff end
+ if tp then tp:load() end
+ end, ON.POST_UPDATE)
+ pause:frame_advance()
+ end
+ if box and box_frame == state.time_level then
+ ctx:draw_rect_filled(screen_aabb(box), 0, box_color)
+ end
+end, ON.GUIFRAME)
+
+set_callback(function()
+ if #states == 0 then
+ states[1] = SaveState:new()
+ end
+ box = nil
+ box_frame = nil
+end, ON.LEVEL)
+
+set_callback(clear_states, ON.PRE_LEVEL_DESTRUCTION)
+
+set_callback(function(slot, loading)
+ if slot > 0 then
+ print(F "Loading save slot {slot}...")
+ else
+ print("Loading custom save slot...")
+ end
+ rewind = state.time_level - loading.time_level
+ if rewind < 0 then
+ print(F "Not forwarding {-rewind} frames, that would break spacetime!")
+ return true
+ else
+ print(F "Rewinding {rewind} frames...")
+ end
+end, ON.PRE_LOAD_STATE)
+
+set_callback(function(slot, loaded)
+ print(F "Rewinded {rewind} frames!")
+ rewind = nil
+end, ON.POST_LOAD_STATE)
diff --git a/src/game_api/rpc.cpp b/src/game_api/rpc.cpp
index e365604a3..a54751d14 100644
--- a/src/game_api/rpc.cpp
+++ b/src/game_api/rpc.cpp
@@ -1904,56 +1904,3 @@ void init_seeded(std::optional seed)
auto* state = State::get().ptr();
isf(state, seed.value_or(state->seed));
}
-
-void copy_state(int from, int to)
-{
- size_t arr = get_address("save_states");
- size_t iterIdx = 1;
- size_t fromBaseState = memory_read(arr + (from - 1) * 8);
- size_t toBaseState = memory_read(arr + (to - 1) * 8);
- do
- {
- size_t copyContent = *(size_t*)((fromBaseState - 8) + iterIdx * 8);
- // variable used to fix pointers that point somewhere in the same Thread
- size_t diff = toBaseState - fromBaseState;
- if (copyContent >= fromBaseState + 0x2000000 || copyContent <= fromBaseState)
- {
- diff = 0;
- }
- *(size_t*)(toBaseState + iterIdx * 8 + -8) = diff + copyContent;
-
- // Almost same code as before, but on the next value, idk why
- copyContent = *(size_t*)(fromBaseState + iterIdx * 8);
- diff = toBaseState - fromBaseState;
- if (copyContent >= fromBaseState + 0x2000000 || copyContent <= fromBaseState)
- {
- diff = 0;
- }
- *(size_t*)(toBaseState + iterIdx * 8) = diff + copyContent;
-
- iterIdx = iterIdx + 2;
- } while (iterIdx != 0x400001);
-};
-
-StateMemory* get_save_state(int slot)
-{
- size_t arr = get_address("save_states");
- size_t base = memory_read(arr + (slot - 1) * 8);
- auto state = reinterpret_cast(base + 0x4a0);
- if (state->screen)
- return state;
- return nullptr;
-}
-
-void invalidate_save_states()
-{
- auto online = get_online();
- if (online->lobby.code != 0)
- return;
- for (int i = 1; i <= 4; ++i)
- {
- auto state = get_save_state(i);
- if (state)
- state->screen = 0;
- }
-}
diff --git a/src/game_api/rpc.hpp b/src/game_api/rpc.hpp
index dbb8abfd7..d901f3420 100644
--- a/src/game_api/rpc.hpp
+++ b/src/game_api/rpc.hpp
@@ -137,6 +137,3 @@ void set_speedhack(std::optional multiplier);
float get_speedhack();
void init_adventure();
void init_seeded(std::optional seed);
-void copy_state(int from, int to);
-StateMemory* get_save_state(int slot);
-void invalidate_save_states();
diff --git a/src/game_api/savestate.cpp b/src/game_api/savestate.cpp
new file mode 100644
index 000000000..6b8823fe6
--- /dev/null
+++ b/src/game_api/savestate.cpp
@@ -0,0 +1,129 @@
+#include "savestate.hpp"
+
+#include "memory.hpp" // for write_mem_prot, write_mem_recoverable
+#include "online.hpp" // for Online
+#include "script/events.hpp" // for pre_load_state
+#include "state.hpp" // for State, get_state_ptr, enum_to_layer
+
+size_t get_state_offset()
+{
+ auto addr = get_address("state_location");
+ if (addr)
+ return memory_read(addr);
+ return 0x4a0;
+}
+
+void copy_save_slot(int from, int to)
+{
+ if ((from == 5 && pre_save_state(to, get_save_state(to))) ||
+ (to == 5 && pre_load_state(from, get_save_state(from))))
+ return;
+ size_t arr = get_address("save_states");
+ size_t fromBaseState = memory_read(arr + (from - 1) * 8);
+ size_t toBaseState = memory_read(arr + (to - 1) * 8);
+ copy_state(fromBaseState, toBaseState);
+ if (from == 5)
+ post_save_state(to, get_save_state(to));
+ else if (to == 5)
+ post_load_state(from, get_save_state(from));
+};
+
+void copy_state(size_t fromBaseState, size_t toBaseState)
+{
+ size_t iterIdx = 1;
+ do
+ {
+ size_t copyContent = *(size_t*)((fromBaseState - 8) + iterIdx * 8);
+ // variable used to fix pointers that point somewhere in the same Thread
+ size_t diff = toBaseState - fromBaseState;
+ if (copyContent >= fromBaseState + 0x2000000 || copyContent <= fromBaseState)
+ {
+ diff = 0;
+ }
+ *(size_t*)(toBaseState + iterIdx * 8 + -8) = diff + copyContent;
+
+ // Almost same code as before, but on the next value, idk why
+ copyContent = *(size_t*)(fromBaseState + iterIdx * 8);
+ diff = toBaseState - fromBaseState;
+ if (copyContent >= fromBaseState + 0x2000000 || copyContent <= fromBaseState)
+ {
+ diff = 0;
+ }
+ *(size_t*)(toBaseState + iterIdx * 8) = diff + copyContent;
+
+ iterIdx = iterIdx + 2;
+ } while (iterIdx != 0x400001);
+};
+
+StateMemory* get_save_state(int slot)
+{
+ size_t arr = get_address("save_states");
+ size_t base = memory_read(arr + (slot - 1) * 8);
+ auto state = reinterpret_cast(base + get_state_offset());
+ if (state->screen)
+ return state;
+ return nullptr;
+}
+
+void invalidate_save_slots()
+{
+ auto online = get_online();
+ if (online->lobby.code != 0)
+ return;
+ for (int i = 1; i <= 4; ++i)
+ {
+ auto state = get_save_state(i);
+ if (state)
+ state->screen = 0;
+ }
+}
+
+SaveState::SaveState()
+{
+ addr = (size_t)malloc(8 * 0x400000);
+ save();
+}
+
+SaveState::~SaveState()
+{
+ clear();
+}
+
+StateMemory* SaveState::get_state()
+{
+ if (!addr)
+ return nullptr;
+ return reinterpret_cast(addr + get_state_offset());
+}
+
+void SaveState::load()
+{
+ if (!addr)
+ return;
+ size_t to = (size_t)(State::get().ptr_main()) - get_state_offset();
+ auto state = reinterpret_cast(addr + get_state_offset());
+ if (pre_load_state(-1, state))
+ return;
+ copy_state(addr, to);
+ post_load_state(-1, state);
+}
+
+void SaveState::save()
+{
+ if (!addr)
+ return;
+ size_t from = (size_t)(State::get().ptr_main()) - get_state_offset();
+ auto state = reinterpret_cast(addr + get_state_offset());
+ if (pre_save_state(-1, state))
+ return;
+ copy_state(from, addr);
+ post_save_state(-1, state);
+}
+
+void SaveState::clear()
+{
+ if (!addr)
+ return;
+ free((void*)addr);
+ addr = 0;
+}
diff --git a/src/game_api/savestate.hpp b/src/game_api/savestate.hpp
new file mode 100644
index 000000000..21880d3ca
--- /dev/null
+++ b/src/game_api/savestate.hpp
@@ -0,0 +1,31 @@
+#pragma once
+
+struct StateMemory;
+
+class SaveState
+{
+ public:
+ /// Create a new temporary SaveState/clone of the main level state. Unlike save_state slots that are preallocated by the game anyway, these will use 32MiB a pop and aren't freed automatically, so make sure to clear them or reuse the same one to save memory. The garbage collector will eventually clear the SaveStates you don't have a handle to any more though.
+ SaveState();
+ ~SaveState();
+
+ /// Access the StateMemory inside a SaveState
+ StateMemory* get_state();
+
+ /// Load a SaveState
+ void load();
+
+ /// Save over a previously allocated SaveState
+ void save();
+
+ /// Delete the SaveState and free the memory. The SaveState can't be used after this.
+ void clear();
+
+ private:
+ size_t addr;
+};
+
+void copy_save_slot(int from, int to);
+void copy_state(size_t fromBaseState, size_t toBaseState);
+StateMemory* get_save_state(int slot);
+void invalidate_save_slots();
diff --git a/src/game_api/script/events.cpp b/src/game_api/script/events.cpp
index 1533a32a7..7b0301757 100644
--- a/src/game_api/script/events.cpp
+++ b/src/game_api/script/events.cpp
@@ -10,6 +10,7 @@
#include "constants.hpp" // for no_return_str
#include "level_api_types.hpp" // for LevelGenRoomData
#include "rpc.hpp" // for game_log, get_adventure_seed
+#include "savestate.hpp" // for invalidate_save_slots
#include "script/lua_backend.hpp" // for LuaBackend, ON, LuaBackend::PreHan...
#include "settings_api.hpp" // for restore_original_settings
#include "state.hpp" // for StateMemory, State
@@ -80,7 +81,7 @@ bool pre_unload_level()
if (!block)
{
g_level_loaded = false;
- invalidate_save_states();
+ invalidate_save_slots();
}
return block;
}
@@ -120,6 +121,30 @@ bool pre_init_layer(LAYER layer)
return block;
}
+bool pre_save_state(int slot, StateMemory* saved)
+{
+ bool block{false};
+ LuaBackend::for_each_backend(
+ [&](LuaBackend::LockedBackend backend)
+ {
+ block = backend->pre_save_state(slot, saved);
+ return !block;
+ });
+ return block;
+}
+
+bool pre_load_state(int slot, StateMemory* loaded)
+{
+ bool block{false};
+ LuaBackend::for_each_backend(
+ [&](LuaBackend::LockedBackend backend)
+ {
+ block = backend->pre_load_state(slot, loaded);
+ return !block;
+ });
+ return block;
+}
+
void post_room_generation()
{
LuaBackend::for_each_backend(
@@ -165,6 +190,24 @@ void post_unload_layer(LAYER layer)
return true;
});
}
+void post_save_state(int slot, StateMemory* saved)
+{
+ LuaBackend::for_each_backend(
+ [&](LuaBackend::LockedBackend backend)
+ {
+ backend->post_save_state(slot, saved);
+ return true;
+ });
+}
+void post_load_state(int slot, StateMemory* loaded)
+{
+ LuaBackend::for_each_backend(
+ [&](LuaBackend::LockedBackend backend)
+ {
+ backend->post_load_state(slot, loaded);
+ return true;
+ });
+}
void on_death_message(STRINGID stringid)
{
LuaBackend::for_each_backend(
diff --git a/src/game_api/script/events.hpp b/src/game_api/script/events.hpp
index 795a5ae25..bbed4af55 100644
--- a/src/game_api/script/events.hpp
+++ b/src/game_api/script/events.hpp
@@ -24,12 +24,16 @@ bool pre_init_level();
bool pre_init_layer(LAYER layer);
bool pre_unload_level();
bool pre_unload_layer(LAYER layer);
+bool pre_save_state(int slot, StateMemory* saved);
+bool pre_load_state(int slot, StateMemory* loaded);
void post_load_screen();
void post_init_layer(LAYER layer);
void post_unload_layer(LAYER layer);
void post_room_generation();
void post_level_generation();
+void post_save_state(int slot, StateMemory* saved);
+void post_load_state(int slot, StateMemory* loaded);
void on_death_message(STRINGID stringid);
std::optional pre_get_feat(FEAT feat);
diff --git a/src/game_api/script/lua_backend.cpp b/src/game_api/script/lua_backend.cpp
index fd64ee738..45896d558 100644
--- a/src/game_api/script/lua_backend.cpp
+++ b/src/game_api/script/lua_backend.cpp
@@ -1814,3 +1814,99 @@ void LuaBackend::on_post(ON event)
}
}
}
+
+bool LuaBackend::pre_save_state(int slot, StateMemory* saved)
+{
+ if (!get_enabled())
+ return false;
+
+ auto now = get_frame_count();
+
+ for (auto& [id, callback] : callbacks)
+ {
+ if (is_callback_cleared(id))
+ continue;
+
+ if (callback.screen == ON::PRE_SAVE_STATE)
+ {
+ set_current_callback(-1, id, CallbackType::Normal);
+ auto return_value = handle_function(this, callback.func, slot, saved).value_or(false);
+ clear_current_callback();
+ callback.lastRan = now;
+ if (return_value)
+ return return_value;
+ }
+ }
+
+ return false;
+}
+
+bool LuaBackend::pre_load_state(int slot, StateMemory* loaded)
+{
+ if (!get_enabled())
+ return false;
+
+ auto now = get_frame_count();
+
+ for (auto& [id, callback] : callbacks)
+ {
+ if (is_callback_cleared(id))
+ continue;
+
+ if (callback.screen == ON::PRE_LOAD_STATE)
+ {
+ set_current_callback(-1, id, CallbackType::Normal);
+ auto return_value = handle_function(this, callback.func, slot, loaded).value_or(false);
+ clear_current_callback();
+ callback.lastRan = now;
+ if (return_value)
+ return return_value;
+ }
+ }
+
+ return false;
+}
+
+void LuaBackend::post_save_state(int slot, StateMemory* saved)
+{
+ if (!get_enabled())
+ return;
+
+ auto now = get_frame_count();
+
+ for (auto& [id, callback] : callbacks)
+ {
+ if (is_callback_cleared(id))
+ continue;
+
+ if (callback.screen == ON::POST_SAVE_STATE)
+ {
+ set_current_callback(-1, id, CallbackType::Normal);
+ handle_function(this, callback.func, slot, saved);
+ clear_current_callback();
+ callback.lastRan = now;
+ }
+ }
+}
+
+void LuaBackend::post_load_state(int slot, StateMemory* loaded)
+{
+ if (!get_enabled())
+ return;
+
+ auto now = get_frame_count();
+
+ for (auto& [id, callback] : callbacks)
+ {
+ if (is_callback_cleared(id))
+ continue;
+
+ if (callback.screen == ON::POST_LOAD_STATE)
+ {
+ set_current_callback(-1, id, CallbackType::Normal);
+ handle_function(this, callback.func, slot, loaded);
+ clear_current_callback();
+ callback.lastRan = now;
+ }
+ }
+}
diff --git a/src/game_api/script/lua_backend.hpp b/src/game_api/script/lua_backend.hpp
index f3d5145e3..8b511f099 100644
--- a/src/game_api/script/lua_backend.hpp
+++ b/src/game_api/script/lua_backend.hpp
@@ -132,6 +132,10 @@ enum class ON
POST_PROCESS_INPUT,
PRE_GAME_LOOP,
POST_GAME_LOOP,
+ PRE_SAVE_STATE,
+ POST_SAVE_STATE,
+ PRE_LOAD_STATE,
+ POST_LOAD_STATE,
BLOCKED_UPDATE,
BLOCKED_GAME_LOOP,
BLOCKED_PROCESS_INPUT,
@@ -383,12 +387,16 @@ class LuaBackend
bool pre_init_layer(LAYER layer);
bool pre_unload_level();
bool pre_unload_layer(LAYER layer);
+ bool pre_save_state(int slot, StateMemory* saved);
+ bool pre_load_state(int slot, StateMemory* loaded);
void post_room_generation();
void post_level_generation();
void post_load_screen();
void post_init_layer(LAYER layer);
void post_unload_layer(LAYER layer);
+ void post_save_state(int slot, StateMemory* saved);
+ void post_load_state(int slot, StateMemory* loaded);
void on_death_message(STRINGID stringid);
std::optional pre_get_feat(FEAT feat);
diff --git a/src/game_api/script/lua_vm.cpp b/src/game_api/script/lua_vm.cpp
index 0ac2989d4..0d47bea9b 100644
--- a/src/game_api/script/lua_vm.cpp
+++ b/src/game_api/script/lua_vm.cpp
@@ -2249,28 +2249,6 @@ end
/// Initializes some seedeed run related values and loads the character select screen, as if starting a new seeded run after entering the seed.
lua["play_seeded"] = init_seeded;
- /// Save current level state to slot 1..4. These save states are invalid after you exit the level, but can be used to rollback to an earlier state in the same level. You probably definitely shouldn't use save state functions during an update, and sync them to the same event outside an update (i.e. GUIFRAME, POST_UPDATE).
- lua["save_state"] = [](int slot)
- {
- if (slot >= 1 && slot <= 4)
- copy_state(5, slot);
- };
-
- /// Load level state from slot 1..4, if a save_state was made in this level.
- lua["load_state"] = [](int slot)
- {
- if (slot >= 1 && slot <= 4 && get_save_state(slot))
- copy_state(slot, 5);
- };
-
- /// Get StateMemory from a save_state slot.
- lua["get_save_state"] = [](int slot) -> StateMemory*
- {
- if (slot >= 1 && slot <= 5)
- return get_save_state(slot);
- return nullptr;
- };
-
lua.create_named_table("INPUTS", "NONE", 0x0, "JUMP", 0x1, "WHIP", 0x2, "BOMB", 0x4, "ROPE", 0x8, "RUN", 0x10, "DOOR", 0x20, "MENU", 0x40, "JOURNAL", 0x80, "LEFT", 0x100, "RIGHT", 0x200, "UP", 0x400, "DOWN", 0x800);
lua.create_named_table("MENU_INPUT", "NONE", 0x0, "SELECT", 0x1, "BACK", 0x2, "DELETE", 0x4, "RANDOM", 0x8, "JOURNAL", 0x10, "LEFT", 0x20, "RIGHT", 0x40, "UP", 0x80, "DOWN", 0x100);
@@ -2452,6 +2430,14 @@ end
ON::PRE_GAME_LOOP,
"POST_GAME_LOOP",
ON::POST_GAME_LOOP,
+ "PRE_SAVE_STATE",
+ ON::PRE_SAVE_STATE,
+ "POST_SAVE_STATE",
+ ON::POST_SAVE_STATE,
+ "PRE_LOAD_STATE",
+ ON::PRE_LOAD_STATE,
+ "POST_LOAD_STATE",
+ ON::POST_LOAD_STATE,
"BLOCKED_UPDATE",
ON::BLOCKED_UPDATE,
"BLOCKED_GAME_LOOP",
@@ -2696,6 +2682,18 @@ end
// Runs right before the main engine loop. Return true to block state updates and menu updates, i.e. to pause inside menus.
// POST_GAME_LOOP
// Runs right after the main engine loop.
+ // PRE_SAVE_STATE
+ // Runs right before the main StateMemory is manually saved to a slot or a custom SaveState. Slot is 1..4 or -1 on custom SaveState. Return true to block save.
+ // Params: int slot, StateMemory saved
+ // POST_SAVE_STATE
+ // Runs right after the main StateMemory is manually saved to a slot or a custom SaveState. Slot is 1..4 or -1 on custom SaveState.
+ // Params: int slot, StateMemory saved
+ // PRE_LOAD_STATE
+ // Runs right before the main StateMemory is manually loaded from a slot or a custom SaveState. Slot is 1..4 or -1 on custom SaveState. Return true to block load.
+ // Params: int slot, StateMemory loaded
+ // POST_LOAD_STATE
+ // Runs right after the main StateMemory is manually loaded from a slot or a custom SaveState. Slot is 1..4 or -1 on custom SaveState.
+ // Params: int slot, StateMemory loaded
// BLOCKED_UPDATE
// Runs instead of POST_UPDATE when anything blocks a PRE_UPDATE. Even runs in Playlunky when Overlunky blocks a PRE_UPDATE.
// BLOCKED_GAME_LOOP
diff --git a/src/game_api/script/usertypes/state_lua.cpp b/src/game_api/script/usertypes/state_lua.cpp
index e6fb19d85..1b4a5615a 100644
--- a/src/game_api/script/usertypes/state_lua.cpp
+++ b/src/game_api/script/usertypes/state_lua.cpp
@@ -18,8 +18,10 @@
#include "items.hpp" // for Items, SelectPlayerSlot, Items::is...
#include "level_api.hpp" // IWYU pragma: keep
#include "online.hpp" // for OnlinePlayer, OnlineLobby, Online
+#include "savestate.hpp" // for SaveState
#include "screen.hpp" // IWYU pragma: keep
#include "screen_arena.hpp" // IWYU pragma: keep
+#include "script/events.hpp" // for pre_load_state
#include "state.hpp" // for StateMemory, State, StateMemory::a...
#include "state_structs.hpp" // for ArenaConfigArenas, ArenaConfigItems
@@ -554,5 +556,38 @@ void register_usertypes(sol::state& lua)
{
State::get().ptr()->speechbubble_timer = 1000;
};
+
+ /// Save current level state to slot 1..4. These save states are invalid and cleared after you exit the current level, but can be used to rollback to an earlier state in the same level. You probably definitely shouldn't use save state functions during an update, and sync them to the same event outside an update (i.e. GUIFRAME, POST_UPDATE). These slots are already allocated by the game, actually used for online rollback, and use no additional memory. Also see SaveState if you need more.
+ lua["save_state"] = [](int slot)
+ {
+ if (slot >= 1 && slot <= 4)
+ {
+ copy_save_slot(5, slot);
+ }
+ };
+
+ /// Load level state from slot 1..4, if a save_state was made in this level.
+ lua["load_state"] = [](int slot)
+ {
+ if (slot >= 1 && slot <= 4 && get_save_state(slot))
+ copy_save_slot(slot, 5);
+ };
+
+ /// Clear save state from slot 1..4.
+ lua["clear_state"] = [](int slot)
+ {
+ if (slot >= 1 && slot <= 4 && get_save_state(slot))
+ get_save_state(slot)->screen = 0;
+ };
+
+ /// Get StateMemory from a save_state slot.
+ lua["get_save_state"] = [](int slot) -> StateMemory*
+ {
+ if (slot >= 1 && slot <= 5)
+ return get_save_state(slot);
+ return nullptr;
+ };
+
+ lua.new_usertype("SaveState", sol::constructors(), "load", &SaveState::load, "save", &SaveState::save, "clear", &SaveState::clear, "get_state", &SaveState::get_state);
}
}; // namespace NState
diff --git a/src/injected/ui.cpp b/src/injected/ui.cpp
index 99e20e146..ab9fe3fcb 100644
--- a/src/injected/ui.cpp
+++ b/src/injected/ui.cpp
@@ -377,9 +377,8 @@ std::map options = {
{"console_alt_keys", false},
{"vsync", true},
{"uncap_unfocused_fps", true},
- {"pause_loading", false},
- {"pause_update_camera", false},
- {"pause_last_instance", false},
+ {"pause_update_camera", true},
+ {"pause_last_instance", true},
{"update_check", true},
{"modifiers_clear_input", true},
{"load_scripts", true},
@@ -2841,8 +2840,11 @@ void clear_script_messages()
bool process_keys(UINT nCode, WPARAM wParam, [[maybe_unused]] LPARAM lParam)
{
ImGuiContext& g = *GImGui;
+ int repeat = (lParam >> 30) & 1U;
+ auto& io = ImGui::GetIO();
+ ImGuiWindow* current = g.NavWindow;
- if (nCode == WM_KEYUP)
+ if (nCode == WM_KEYUP && !io.WantCaptureKeyboard)
{
if (pressed("speedhack_turbo", wParam))
{
@@ -2854,9 +2856,13 @@ bool process_keys(UINT nCode, WPARAM wParam, [[maybe_unused]] LPARAM lParam)
UI::speedhack(g_speedhack_old_multiplier);
g_speedhack_old_multiplier = 1.0f;
}
- else if (pressed("peek_layer", wParam))
+ else if (pressed("peek_layer", wParam) && peek_layer)
{
peek_layer = false;
+ g_state->layer_transition_timer = 15;
+ g_state->transition_to_layer = (g_state->camera_layer + 1) % 2;
+ g_state->camera_layer = g_state->transition_to_layer;
+ UI::set_camera_layer_control_enabled(!peek_layer);
}
}
@@ -2865,9 +2871,6 @@ bool process_keys(UINT nCode, WPARAM wParam, [[maybe_unused]] LPARAM lParam)
return false;
}
- int repeat = (lParam >> 30) & 1U;
- auto& io = ImGui::GetIO();
- ImGuiWindow* current = g.NavWindow;
g_speedhack_ui_multiplier = UI::get_speedhack();
if (current != nullptr && current == ImGui::FindWindowByName("KeyCapture"))
@@ -3543,9 +3546,13 @@ bool process_keys(UINT nCode, WPARAM wParam, [[maybe_unused]] LPARAM lParam)
}
g_selected_ids.clear();
}
- else if (pressed("peek_layer", wParam))
+ else if (pressed("peek_layer", wParam) && !repeat)
{
peek_layer = true;
+ UI::set_camera_layer_control_enabled(!peek_layer);
+ g_state->layer_transition_timer = 15;
+ g_state->transition_to_layer = (g_state->camera_layer + 1) % 2;
+ g_state->camera_layer = g_state->transition_to_layer;
}
else if (pressed("save_state_1", wParam))
{
@@ -4509,7 +4516,7 @@ void render_grid(ImColor gridcolor = ImColor(1.0f, 1.0f, 1.0f, 0.2f))
{
for (unsigned int y = 0; y < g_state->h; ++y)
{
- auto room_temp = UI::get_room_template(x, y, peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer);
+ auto room_temp = UI::get_room_template(x, y, g_state->camera_layer);
if (room_temp.has_value())
{
auto room_name = UI::get_room_template_name(room_temp.value());
@@ -5079,7 +5086,7 @@ void render_clickhandler()
if (options["draw_hitboxes"] && g_state->screen != 5)
{
static const auto olmec = to_id("ENT_TYPE_ACTIVEFLOOR_OLMEC");
- for (auto entity : UI::get_entities_by({}, g_hitbox_mask, (LAYER)(peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer)))
+ for (auto entity : UI::get_entities_by({}, g_hitbox_mask, (LAYER)g_state->camera_layer))
{
auto ent = get_entity_ptr(entity);
if (!ent)
@@ -5133,17 +5140,17 @@ void render_clickhandler()
to_id("ENT_TYPE_FLOOR_SHOPKEEPER_GENERATOR"),
to_id("ENT_TYPE_FLOOR_SUNCHALLENGE_GENERATOR"),
};
- for (auto entity : UI::get_entities_by(additional_fixed_entities, 0x180, (LAYER)(peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer))) // FLOOR | ACTIVEFLOOR
+ for (auto entity : UI::get_entities_by(additional_fixed_entities, 0x180, (LAYER)g_state->camera_layer)) // FLOOR | ACTIVEFLOOR
{
auto ent = get_entity_ptr(entity);
render_hitbox(ent, false, ImColor(0, 255, 255, 150));
}
- for (auto entity : UI::get_entities_by({(ENT_TYPE)CUSTOM_TYPE::TRIGGER}, 0x1000, (LAYER)(peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer))) // LOGICAL
+ for (auto entity : UI::get_entities_by({(ENT_TYPE)CUSTOM_TYPE::TRIGGER}, 0x1000, (LAYER)g_state->camera_layer)) // LOGICAL
{
auto ent = get_entity_ptr(entity);
render_hitbox(ent, false, ImColor(255, 0, 0, 150));
}
- for (auto entity : UI::get_entities_by({to_id("ENT_TYPE_LOGICAL_DOOR")}, 0x1000, (LAYER)(peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer))) // DOOR
+ for (auto entity : UI::get_entities_by({to_id("ENT_TYPE_LOGICAL_DOOR")}, 0x1000, (LAYER)g_state->camera_layer)) // DOOR
{
auto ent = get_entity_ptr(entity);
render_hitbox(ent, false, ImColor(255, 180, 45, 150), false, true);
@@ -5201,7 +5208,7 @@ void render_clickhandler()
back_fill.Value.w = 0.25f;
if (update_entity())
{
- auto this_layer = (peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer) == g_entity->layer;
+ auto this_layer = g_state->camera_layer == g_entity->layer;
render_hitbox(g_entity, true, this_layer ? front_col : back_col);
}
for (auto entity : g_selected_ids)
@@ -5209,7 +5216,7 @@ void render_clickhandler()
auto ent = get_entity_ptr(entity);
if (ent)
{
- if (ent->layer == (peek_layer ? g_state->camera_layer ^ 1 : g_state->camera_layer))
+ if (ent->layer == g_state->camera_layer)
render_hitbox(ent, false, front_fill, true);
else
render_hitbox(ent, false, back_fill, true);
@@ -7078,7 +7085,7 @@ void render_powerup(PowerupCapable* ent, int uid, const char* section)
ImGui::Text("%s", pname);
ImGui::SameLine();
ImGui::PushID(uid);
- if (ImGui::Button("Remove"))
+ if (ImGui::Button("Remove##RemovePowerup"))
{
ent->as()->remove_powerup(ptype);
}
@@ -8161,17 +8168,6 @@ struct TextureViewer
static TextureViewer texture_viewer{0, -1};
void render_vanilla_stuff()
{
- if (peek_layer && g_state->layer_transition_timer == 0)
- {
- uint8_t other_layer = g_state->camera_layer ? 0 : 1;
- auto [bbox_left, bbox_top] = UI::click_position(-1.0f, 1.0f);
- auto [bbox_right, bbox_bottom] = UI::click_position(1.0f, -1.0f);
- for (uint8_t i = 52; i > 0; --i)
- {
- render_draw_depth(g_state->layers[other_layer], i, bbox_left, bbox_bottom, bbox_right, bbox_top);
- }
- }
-
if (!hide_ui && options["draw_hotbar"])
render_hotbar_textures();
diff --git a/src/injected/ui_util.cpp b/src/injected/ui_util.cpp
index c24df6699..33e427fe2 100644
--- a/src/injected/ui_util.cpp
+++ b/src/injected/ui_util.cpp
@@ -24,6 +24,7 @@
#include "memory.hpp" //
#include "render_api.hpp" // for RenderInfo
#include "rpc.hpp" // for get_entities_at, entity_get_ite...
+#include "savestate.hpp" // for copy_save_slot
#include "search.hpp" //
#include "spawn_api.hpp" // for spawn_liquid, spawn_companion
#include "state.hpp" // for State, StateMemory
@@ -831,10 +832,15 @@ void UI::set_adventure_seed(int64_t first, int64_t second)
void UI::copy_state(int from, int to)
{
- ::copy_state(from, to);
+ ::copy_save_slot(from, to);
}
StateMemory* UI::get_save_state(int slot)
{
return ::get_save_state(slot);
}
+
+void UI::set_camera_layer_control_enabled(bool enable)
+{
+ ::set_camera_layer_control_enabled(enable);
+}
diff --git a/src/injected/ui_util.hpp b/src/injected/ui_util.hpp
index 991c97f4d..2964ef594 100644
--- a/src/injected/ui_util.hpp
+++ b/src/injected/ui_util.hpp
@@ -100,4 +100,5 @@ class UI
static void set_adventure_seed(int64_t first, int64_t second);
static void copy_state(int from, int to);
static StateMemory* get_save_state(int slot);
+ static void set_camera_layer_control_enabled(bool enable);
};