diff --git a/docs/game_data/spel2.lua b/docs/game_data/spel2.lua index 0794e81a2..120d2f8d0 100644 --- a/docs/game_data/spel2.lua +++ b/docs/game_data/spel2.lua @@ -1378,6 +1378,18 @@ 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). +---@param slot integer +---@return nil +function save_state(slot) end +---Load level state from slot 1..4, if a save_state was made in this level. +---@param slot integer +---@return nil +function load_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 diff --git a/docs/src/includes/_globals.md b/docs/src/includes/_globals.md index ff4bfb3ba..272fc1266 100644 --- a/docs/src/includes/_globals.md +++ b/docs/src/includes/_globals.md @@ -1515,6 +1515,15 @@ Retrieves the current value of the performance counter, which is a high resoluti Retrieves the frequency of the performance counter. The frequency of the performance counter is fixed at system boot and is consistent across all processors. Therefore, the frequency need only be queried upon application initialization, and the result can be cached. +### get_save_state + + +> Search script examples for [get_save_state](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=get_save_state) + +#### [StateMemory](#StateMemory) get_save_state(int slot) + +Get [StateMemory](#StateMemory) from a save_state slot. + ### get_setting @@ -1681,6 +1690,15 @@ Immediately ends the run with the death screen, also calls the [save_progress](# Immediately load a screen based on [state](#state).screen_next and stuff +### load_state + + +> Search script examples for [load_state](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=load_state) + +#### nil load_state(int slot) + +Load level state from slot 1..4, if a save_state was made in this level. + ### lowbias32 @@ -1762,6 +1780,15 @@ Saves the game to savegame.sav, unless game saves are blocked in the settings. A Runs the [ON](#ON).SAVE callback. Fails and returns false, if you're trying to save too often (2s). +### save_state + + +> Search script examples for [save_state](https://github.com/spelunky-fyi/overlunky/search?l=Lua&q=save_state) + +#### 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). + ### script_enabled diff --git a/examples/barrymod.lua b/examples/barrymod.lua index d6fe77524..47855bd7a 100644 --- a/examples/barrymod.lua +++ b/examples/barrymod.lua @@ -1,130 +1,56 @@ meta.name = 'Barrymod' -meta.version = 'WIP' -meta.description = 'Restarts the current level on death or manually like nothing happened. Not everything from Inventory is implemented, cause it\'s not in the api yet. Sometimes also gets the level gen wrong and atm screws up journal progress by design.' +meta.version = '1.0' +meta.description = 'Creates checkpoints and restarts the current level on death like nothing happened.' meta.author = 'Dregu' -local status = {} -local restart = false +register_option_bool('save_early', 'Multiverse of Madness Mode', + 'Rerolls the level generation on death,\nbut keeps quest state and inventory.', + false) -local vars = { - state = {'seed', 'level_count', 'time_total', 'shoppie_aggro', 'shoppie_aggro_next', 'merchant_aggro', 'kali_favor', 'kali_status', 'kali_altars_destroyed', 'level_flags', 'quest_flags', 'journal_flags', 'presence_flags', 'special_visibility_flags', 'kills_npc', 'damage_taken', 'time_last_level', 'saved_dogs', 'saved_cats', 'saved_hamsters', 'money_last_levels', 'money_shop_total', 'correct_ushabti'}, - quests = {'yang_state', 'jungle_susters_flags', 'van_horsing_state', 'sparrow_state', 'madame_tusk_state', 'beg_state'}, - inventory = {'health', 'bombs', 'ropes', 'held_item', 'held_item_metadata', 'kapala_blood_amount', 'poison_tick_timer', 'cursed', 'elixir_buff', 'mount_type', 'mount_metadata', 'kills_level', 'kills_total', 'collected_money_total'} -} - -local names = {} -for i,v in pairs(ENT_TYPE) do - names[v] = i -end - -local function save(from, arr) - for i,v in ipairs(arr) do - status[v] = from[v] - end - print("Saved state") -end - -local function load(to, arr) - for i,v in ipairs(arr) do - if status[v] ~= nil then - to[v] = status[v] - end - end - print("Loaded state") -end - -local function clear() - status = {} - status.back = -1 - status.power = {} - status.rng = {} - print("Cleared state") -end - -local function restart_level() - restart = true - state.screen_next = SCREEN.LEVEL - state.screen_last = SCREEN.TRANSITION - state.world_next = state.world - state.level_next = state.level - state.theme_next = state.theme - state.quest_flags = 1 - state.loading = 2 -end - -register_option_button('restart', 'Restart level', function() - restart_level() +register_option_callback('buttons', nil, function(ctx) + if ctx:win_button('Quick Save') then save_state(1) end + ctx:win_inline() + if ctx:win_button('Quick Load') then load_state(1) end end) set_callback(function() - if state.items.player_inventory[1].health < 1 then - state.items.player_inventory[1].health = 4 + if not options.save_early then + save_state(1) + end + for _, p in pairs(players) do + set_on_player_instagib(p.uid, function(e) + -- can't load_state directly here, cause we're still in the middle of an update + restart = true + end) end +end, ON.LEVEL) - if restart then - if status.rng then - for i,v in pairs(status.rng) do - prng:set_pair(i, v.a, v.b) - end - end - load(state, vars.state) - load(state.quests, vars.quests) - load(state.items.player_inventory[1], vars.inventory) - else - if not status.rng then - status.rng = {} - end - for i=0,9 do - local a,b = prng:get_pair(i) - status.rng[i] = { a=a, b=b } - end - save(state, vars.state) - save(state.quests, vars.quests) - save(state.items.player_inventory[1], vars.inventory) +set_callback(function() + if options.save_early then + -- for whatever prng related reason, loading a save created at this point will reroll the level rng, which is a neat I guess + save_state(1) end end, ON.PRE_LEVEL_GENERATION) set_callback(function() - local ent = players[1] if restart then - if status.power then - for i,v in ipairs(status.power) do - local m = string.find(names[v], 'PACK') - if not m and not ent:has_powerup(v) then - ent:give_powerup(v) - end - end - end - if status.back and status.back ~= -1 and ent:worn_backitem() == -1 then - pick_up(ent.uid, spawn(status.back, 0, 0, LAYER.PLAYER, 0, 0)) - end - else - status.back = -1 - local backitem = worn_backitem(players[1].uid) - if backitem ~= -1 then - status.back = get_entity(backitem).type.id - end - - status.power = {} - for i,v in ipairs(players[1]:get_powerups()) do - status.power[i] = v - end + restart = nil + -- load the save state we made earlier, after updates to not mess with the running state + load_state(1) end - - set_on_kill(ent.uid, function() - restart_level() - end) - - set_on_destroy(ent.uid, function() - restart_level() - end) - - restart = false -end, ON.LEVEL) +end, ON.POST_UPDATE) set_callback(function() - local tile = get_grid_entity_at(6, 121, LAYER.FRONT) + if state.screen ~= SCREEN.TRANSITION then return end + local tile = get_entity(get_grid_entity_at(6, 121, LAYER.FRONT)) if tile then - get_entity(tile):destroy() + tile:remove() + tile = get_entity(get_grid_entity_at(6, 120, LAYER.FRONT)) + if tile then + tile:decorate_internal() + end + else + tile = get_entity(spawn_grid_entity(ENT_TYPE.FLOOR_GENERIC, 6, 121, LAYER.FRONT)) + tile:decorate_internal() end -end, ON.TRANSITION) +end, ON.POST_LEVEL_GENERATION) diff --git a/src/game_api/rpc.cpp b/src/game_api/rpc.cpp index 5c54ee53b..9d3cb9487 100644 --- a/src/game_api/rpc.cpp +++ b/src/game_api/rpc.cpp @@ -1903,3 +1903,53 @@ 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() +{ + 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 d901f3420..dbb8abfd7 100644 --- a/src/game_api/rpc.hpp +++ b/src/game_api/rpc.hpp @@ -137,3 +137,6 @@ 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/script/events.cpp b/src/game_api/script/events.cpp index 631e8eb85..1533a32a7 100644 --- a/src/game_api/script/events.cpp +++ b/src/game_api/script/events.cpp @@ -78,7 +78,10 @@ bool pre_unload_level() return !block; }); if (!block) + { g_level_loaded = false; + invalidate_save_states(); + } return block; } bool pre_init_level() diff --git a/src/game_api/script/lua_vm.cpp b/src/game_api/script/lua_vm.cpp index c79d999c2..f14ce496e 100644 --- a/src/game_api/script/lua_vm.cpp +++ b/src/game_api/script/lua_vm.cpp @@ -2249,6 +2249,28 @@ 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); diff --git a/src/game_api/search.cpp b/src/game_api/search.cpp index 89338b408..f5d806fa8 100644 --- a/src/game_api/search.cpp +++ b/src/game_api/search.cpp @@ -2110,6 +2110,11 @@ std::unordered_map g_address_rules{ PatternCommandBuffer{} .from_exe_base(0x22b7ca10) // TODO }, + { + "save_states"sv, + PatternCommandBuffer{} + .from_exe_base(0x22e0d1d0) // TODO + }, }; std::unordered_map g_cached_addresses; diff --git a/src/injected/ui.cpp b/src/injected/ui.cpp index fd4eb6685..ba51d4dc9 100644 --- a/src/injected/ui.cpp +++ b/src/injected/ui.cpp @@ -148,6 +148,14 @@ std::map default_keys{ {"hotbar_8", '8'}, {"hotbar_9", '9'}, {"hotbar_0", '0'}, + {"load_state_1", OL_KEY_SHIFT | VK_F1}, + {"load_state_2", OL_KEY_SHIFT | VK_F2}, + {"load_state_3", OL_KEY_SHIFT | VK_F3}, + {"load_state_4", OL_KEY_SHIFT | VK_F4}, + {"save_state_1", OL_KEY_SHIFT | VK_F5}, + {"save_state_2", OL_KEY_SHIFT | VK_F6}, + {"save_state_3", OL_KEY_SHIFT | VK_F7}, + {"save_state_4", OL_KEY_SHIFT | VK_F8}, {"toggle_hotbar", OL_KEY_CTRL | OL_KEY_SHIFT | 'B'}, {"spawn_layer_door", OL_KEY_SHIFT | VK_RETURN}, {"spawn_warp_door", OL_KEY_CTRL | OL_KEY_SHIFT | VK_RETURN}, @@ -2793,6 +2801,23 @@ void toggle_lights() } } +void load_state(int slot) +{ + StateMemory* target = UI::get_save_state(slot); + if (!target) + return; + if (g_state->screen == 14 && target->screen != 14) + { + g_state->screen = 12; + g_game_manager->journal_ui->fade_timer = 15; + g_game_manager->journal_ui->state = 5; + g_state->camera->focus_offset_x = 0; + g_state->camera->focus_offset_y = 0; + set_camera_bounds(true); + } + UI::copy_state(slot, 5); +} + bool process_keys(UINT nCode, WPARAM wParam, [[maybe_unused]] LPARAM lParam) { ImGuiContext& g = *GImGui; @@ -3502,6 +3527,38 @@ bool process_keys(UINT nCode, WPARAM wParam, [[maybe_unused]] LPARAM lParam) { peek_layer = true; } + else if (pressed("save_state_1", wParam)) + { + UI::copy_state(5, 1); + } + else if (pressed("save_state_2", wParam)) + { + UI::copy_state(5, 2); + } + else if (pressed("save_state_3", wParam)) + { + UI::copy_state(5, 3); + } + else if (pressed("save_state_4", wParam)) + { + UI::copy_state(5, 4); + } + else if (pressed("load_state_1", wParam)) + { + load_state(1); + } + else if (pressed("load_state_2", wParam)) + { + load_state(2); + } + else if (pressed("load_state_3", wParam)) + { + load_state(3); + } + else if (pressed("load_state_4", wParam)) + { + load_state(4); + } else { return false; @@ -8433,6 +8490,27 @@ void render_game_props() } if (submenu("State")) { + for (int i = 1; i <= 4; ++i) + { + if (ImGui::Button(fmt::format(" {} ##SaveState{}", i, i).c_str())) + UI::copy_state(5, i); + tooltip("Save current level state", fmt::format("save_state_{}", i).c_str()); + ImGui::SameLine(); + } + ImGui::Text("Save state"); + + for (int i = 1; i <= 4; ++i) + { + bool valid = UI::get_save_state(i) != nullptr; + ImGui::BeginDisabled(!valid); + if (ImGui::Button(fmt::format(" {} ##LoadState{}", i, i).c_str())) + load_state(i); + ImGui::EndDisabled(); + tooltip("Load current level state", fmt::format("load_state_{}", i).c_str()); + ImGui::SameLine(); + } + ImGui::Text("Load state"); + render_screen("Current screen", g_state->screen); render_screen("Last screen", g_state->screen_last); render_screen("Next screen", g_state->screen_next); diff --git a/src/injected/ui_util.cpp b/src/injected/ui_util.cpp index c30306edc..3e792d0a3 100644 --- a/src/injected/ui_util.cpp +++ b/src/injected/ui_util.cpp @@ -824,3 +824,13 @@ void UI::set_adventure_seed(int64_t first, int64_t second) { ::set_adventure_seed(first, second); } + +void UI::copy_state(int from, int to) +{ + ::copy_state(from, to); +} + +StateMemory* UI::get_save_state(int slot) +{ + return ::get_save_state(slot); +} diff --git a/src/injected/ui_util.hpp b/src/injected/ui_util.hpp index af9cfdfbb..991c97f4d 100644 --- a/src/injected/ui_util.hpp +++ b/src/injected/ui_util.hpp @@ -16,6 +16,7 @@ class SparkTrap; struct SaveData; struct Illumination; struct AABB; +struct StateMemory; constexpr uint32_t set_flag(uint32_t& flags, int bit) { @@ -97,4 +98,6 @@ class UI static void init_seeded(uint32_t seed); static std::pair get_adventure_seed(std::optional run_start); 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); };