diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b14da41cb..eaa19b552 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -3,6 +3,7 @@ * * Implementation of the main game initialization functions. */ +#include #include #include #include @@ -175,6 +176,72 @@ bool was_archives_init = false; /** To know if surfaces have been initialized or not */ bool was_window_init = false; bool was_ui_init = false; +using TickCount = decltype(SDL_GetTicks()); + +TickCount autoSaveNextTimerDueAt = 0; +AutoSaveReason pendingAutoSaveReason = AutoSaveReason::None; +/** Prevent autosave from running immediately after session start before player interaction. */ +bool hasEnteredActiveGameplay = false; +TickCount autoSaveCooldownUntil = 0; +TickCount autoSaveCombatCooldownUntil = 0; +bool wasAutoSaveEnabled = false; +constexpr TickCount AutoSaveCooldownMilliseconds = 5000; +constexpr TickCount AutoSaveCombatCooldownMilliseconds = 4000; + +bool HaveTicksPassed(TickCount now, TickCount due) +{ +#ifdef USE_SDL3 + return now >= due; +#else + return SDL_TICKS_PASSED(now, due); +#endif +} + +bool HaveTicksPassed(TickCount due) +{ + return HaveTicksPassed(SDL_GetTicks(), due); +} + +TickCount GetAutoSaveIntervalMilliseconds() +{ + return static_cast(std::max(1, *GetOptions().Gameplay.autoSaveIntervalSeconds)) * 1000; +} + +int GetAutoSavePriority(AutoSaveReason reason) +{ + switch (reason) { + case AutoSaveReason::BossKill: + return 4; + case AutoSaveReason::TownEntry: + return 3; + case AutoSaveReason::UniquePickup: + return 2; + case AutoSaveReason::Timer: + return 1; + case AutoSaveReason::None: + return 0; + } + + return 0; +} + +const char *GetAutoSaveReasonName(AutoSaveReason reason) +{ + switch (reason) { + case AutoSaveReason::None: + return "None"; + case AutoSaveReason::Timer: + return "Timer"; + case AutoSaveReason::TownEntry: + return "TownEntry"; + case AutoSaveReason::BossKill: + return "BossKill"; + case AutoSaveReason::UniquePickup: + return "UniquePickup"; + } + + return "Unknown"; +} void StartGame(interface_mode uMsg) { @@ -194,6 +261,12 @@ void StartGame(interface_mode uMsg) sgnTimeoutCurs = CURSOR_NONE; sgbMouseDown = CLICK_NONE; LastPlayerAction = PlayerActionType::None; + hasEnteredActiveGameplay = false; + autoSaveCooldownUntil = 0; + autoSaveCombatCooldownUntil = 0; + pendingAutoSaveReason = AutoSaveReason::None; + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + wasAutoSaveEnabled = *GetOptions().Gameplay.autoSaveEnabled; } void FreeGame() @@ -1553,6 +1626,32 @@ void GameLogic() RedrawViewport(); pfile_update(false); + if (!hasEnteredActiveGameplay && LastPlayerAction != PlayerActionType::None) + hasEnteredActiveGameplay = true; + + const bool autoSaveEnabled = *GetOptions().Gameplay.autoSaveEnabled; + if (autoSaveEnabled != wasAutoSaveEnabled) { + if (!autoSaveEnabled) + pendingAutoSaveReason = AutoSaveReason::None; + + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + wasAutoSaveEnabled = autoSaveEnabled; + } + + if (autoSaveEnabled) { + const TickCount now = SDL_GetTicks(); + if (HaveTicksPassed(now, autoSaveNextTimerDueAt)) { + QueueAutoSave(AutoSaveReason::Timer); + } + } + + if (HasPendingAutoSave() && IsAutoSaveSafe()) { + if (AttemptAutoSave(pendingAutoSaveReason)) { + pendingAutoSaveReason = AutoSaveReason::None; + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + } + } + plrctrls_after_game_logic(); } @@ -1802,6 +1901,127 @@ const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueCha } // namespace +bool HasActiveMonstersForAutoSave(); + +bool IsAutoSaveSafe() +{ + if (gbIsMultiplayer || !gbRunGame) + return false; + + if (!hasEnteredActiveGameplay) + return false; + + if (!HaveTicksPassed(autoSaveCooldownUntil)) + return false; + + if (!HaveTicksPassed(autoSaveCombatCooldownUntil)) + return false; + + if (movie_playing || PauseMode != 0 || gmenu_is_active() || IsPlayerInStore()) + return false; + + if (MyPlayer == nullptr || IsPlayerDead() || MyPlayer->_pLvlChanging || LoadingMapObjects) + return false; + + if (qtextflag || DropGoldFlag || IsWithdrawGoldOpen || pcurs != CURSOR_HAND) + return false; + + if (leveltype != DTYPE_TOWN && HasActiveMonstersForAutoSave()) + return false; + + return true; +} + +void MarkCombatActivity() +{ + autoSaveCombatCooldownUntil = SDL_GetTicks() + AutoSaveCombatCooldownMilliseconds; +} + +bool HasActiveMonstersForAutoSave() +{ + for (const Monster &monster : Monsters) { + if (monster.hitPoints <= 0 || monster.mode == MonsterMode::Death || monster.mode == MonsterMode::Petrified) + continue; + + if (monster.activeForTicks == 0) + continue; + + return true; + } + + return false; +} + +int GetSecondsUntilNextAutoSave() +{ + if (!*GetOptions().Gameplay.autoSaveEnabled) + return -1; + + if (HasPendingAutoSave()) + return 0; + + const TickCount now = SDL_GetTicks(); + if (HaveTicksPassed(now, autoSaveNextTimerDueAt)) + return 0; + + const TickCount remainingMilliseconds = autoSaveNextTimerDueAt - now; + return static_cast((remainingMilliseconds + 999) / 1000); +} + +bool HasPendingAutoSave() +{ + return pendingAutoSaveReason != AutoSaveReason::None; +} + +void RequestAutoSave(AutoSaveReason reason) +{ + if (!*GetOptions().Gameplay.autoSaveEnabled) + return; + + if (gbIsMultiplayer) + return; + + QueueAutoSave(reason); +} + +void QueueAutoSave(AutoSaveReason reason) +{ + if (gbIsMultiplayer) + return; + + if (!*GetOptions().Gameplay.autoSaveEnabled) + return; + + if (GetAutoSavePriority(reason) > GetAutoSavePriority(pendingAutoSaveReason)) { + pendingAutoSaveReason = reason; + LogVerbose("Autosave queued: {}", GetAutoSaveReasonName(reason)); + } +} + +bool AttemptAutoSave(AutoSaveReason reason) +{ + if (!IsAutoSaveSafe()) + return false; + + const EventHandler saveProc = SetEventHandler(DisableInputEventHandler); + const TickCount currentTime = SDL_GetTicks(); + const SaveResult saveResult = SaveGame(SaveKind::Auto); + const TickCount afterSaveTime = SDL_GetTicks(); + const bool saveSucceeded = saveResult == SaveResult::Success; + + autoSaveCooldownUntil = afterSaveTime + AutoSaveCooldownMilliseconds; + if (saveSucceeded) { + autoSaveNextTimerDueAt = afterSaveTime + GetAutoSaveIntervalMilliseconds(); + if (reason != AutoSaveReason::Timer) { + const int timeElapsed = static_cast(afterSaveTime - currentTime); + const int displayTime = std::max(500, 1000 - timeElapsed); + InitDiabloMsg(EMSG_GAME_SAVED, displayTime); + } + } + SetEventHandler(saveProc); + return saveSucceeded; +} + void InitKeymapActions() { Options &options = GetOptions(); @@ -3434,6 +3654,8 @@ tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) CompleteProgress(); LoadGameLevelCalculateCursor(); + if (leveltype == DTYPE_TOWN && lvldir != ENTRY_LOAD && !firstflag) + ::devilution::RequestAutoSave(AutoSaveReason::TownEntry); return {}; } diff --git a/Source/diablo.h b/Source/diablo.h index ad28ebb82..86c378cc9 100644 --- a/Source/diablo.h +++ b/Source/diablo.h @@ -68,6 +68,14 @@ enum class PlayerActionType : uint8_t { OperateObject, }; +enum class AutoSaveReason : uint8_t { + None, + Timer, + TownEntry, + BossKill, + UniquePickup, +}; + extern uint32_t DungeonSeeds[NUMLEVELS]; extern DVL_API_FOR_TEST std::optional LevelSeeds[NUMLEVELS]; extern Point MousePosition; @@ -101,6 +109,13 @@ bool PressEscKey(); void DisableInputEventHandler(const SDL_Event &event, uint16_t modState); tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir); bool IsDiabloAlive(bool playSFX); +void MarkCombatActivity(); +bool IsAutoSaveSafe(); +int GetSecondsUntilNextAutoSave(); +bool HasPendingAutoSave(); +void RequestAutoSave(AutoSaveReason reason); +void QueueAutoSave(AutoSaveReason reason); +bool AttemptAutoSave(AutoSaveReason reason); void PrintScreen(SDL_Keycode vkey); /** diff --git a/Source/gamemenu.cpp b/Source/gamemenu.cpp index f1f997c75..47ba4b5e2 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -5,11 +5,15 @@ */ #include "gamemenu.h" +#include +#include + #ifdef USE_SDL3 #include #endif #include "cursor.h" +#include "diablo.h" #include "diablo_msg.hpp" #include "engine/backbuffer_state.hpp" #include "engine/demomode.h" @@ -36,6 +40,9 @@ bool isGameMenuOpen = false; namespace { +constexpr const char *SaveFailedPreservedMessage = N_("Save failed. The previous save is still available."); +constexpr const char *SaveFailedNoValidMessage = N_("Save failed. No valid save is available."); + // Forward-declare menu handlers, used by the global menu structs below. void GamemenuPrevious(bool bActivate); void GamemenuNewGame(bool bActivate); @@ -89,6 +96,8 @@ const char *const SoundToggleNames[] = { N_("Sound Disabled"), }; +std::string saveGameMenuLabel; + void GamemenuUpdateSingle() { sgSingleMenu[2].setEnabled(gbValidSaveFile); @@ -98,6 +107,27 @@ void GamemenuUpdateSingle() sgSingleMenu[0].setEnabled(enable); } +std::string_view GetSaveGameMenuLabel() +{ +#ifndef _DEBUG + return _("Save Game"); +#else + if (HasPendingAutoSave()) { + saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:s})")), _("ready")); + return saveGameMenuLabel; + } + + const int seconds = GetSecondsUntilNextAutoSave(); + if (seconds < 0) { + saveGameMenuLabel = _("Save Game"); + return saveGameMenuLabel; + } + + saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); + return saveGameMenuLabel; +#endif +} + void GamemenuPrevious(bool /*bActivate*/) { gamemenu_on(); @@ -350,12 +380,26 @@ void gamemenu_save_game(bool /*bActivate*/) RedrawEverything(); DrawAndBlit(); const uint32_t currentTime = SDL_GetTicks(); - SaveGame(); + const SaveResult saveResult = SaveGame(SaveKind::Manual); ClrDiabloMsg(); - InitDiabloMsg(EMSG_GAME_SAVED, currentTime + 1000 - SDL_GetTicks()); + switch (saveResult) { + case SaveResult::Success: { + const uint32_t afterSaveTime = SDL_GetTicks(); + const int timeElapsed = static_cast(afterSaveTime - currentTime); + const int displayTime = std::max(500, 1000 - timeElapsed); + InitDiabloMsg(EMSG_GAME_SAVED, displayTime); + break; + } + case SaveResult::FailedButPreviousSavePreserved: + InitDiabloMsg(_(SaveFailedPreservedMessage)); + break; + case SaveResult::FailedNoValidSave: + InitDiabloMsg(_(SaveFailedNoValidMessage)); + break; + } RedrawEverything(); NewCursor(CURSOR_HAND); - if (CornerStone.activated) { + if (saveResult == SaveResult::Success && CornerStone.activated) { CornerstoneSave(); if (!demo::IsRunning()) SaveOptions(); } @@ -363,6 +407,14 @@ void gamemenu_save_game(bool /*bActivate*/) SetEventHandler(saveProc); } +std::string_view GetGamemenuText(const TMenuItem &menuItem) +{ + if (menuItem.fnMenu == &gamemenu_save_game) + return GetSaveGameMenuLabel(); + + return _(menuItem.pszStr); +} + void gamemenu_on() { isGameMenuOpen = true; diff --git a/Source/gamemenu.h b/Source/gamemenu.h index 1b6abd731..1024e5db6 100644 --- a/Source/gamemenu.h +++ b/Source/gamemenu.h @@ -5,8 +5,12 @@ */ #pragma once +#include + namespace devilution { +struct TMenuItem; + void gamemenu_on(); void gamemenu_off(); void gamemenu_handle_previous(); @@ -14,6 +18,7 @@ void gamemenu_exit_game(bool bActivate); void gamemenu_quit_game(bool bActivate); void gamemenu_load_game(bool bActivate); void gamemenu_save_game(bool bActivate); +std::string_view GetGamemenuText(const TMenuItem &menuItem); extern bool isGameMenuOpen; diff --git a/Source/gmenu.cpp b/Source/gmenu.cpp index 6e1c5230d..df1c026a5 100644 --- a/Source/gmenu.cpp +++ b/Source/gmenu.cpp @@ -29,6 +29,7 @@ #include "engine/render/clx_render.hpp" #include "engine/render/primitive_render.hpp" #include "engine/render/text_render.hpp" +#include "gamemenu.h" #include "headless_mode.hpp" #include "options.h" #include "stores.h" @@ -123,11 +124,12 @@ int GmenuGetLineWidth(TMenuItem *pItem) if (pItem->isSlider()) return SliderItemWidth; - return GetLineWidth(_(pItem->pszStr), GameFont46, 2); + return GetLineWidth(GetGamemenuText(*pItem), GameFont46, 2); } void GmenuDrawMenuItem(const Surface &out, TMenuItem *pItem, int y) { + const std::string_view menuText = GetGamemenuText(*pItem); const int w = GmenuGetLineWidth(pItem); if (pItem->isSlider()) { const int uiPositionX = GetUIRectangle().position.x; @@ -142,7 +144,7 @@ void GmenuDrawMenuItem(const Surface &out, TMenuItem *pItem, int y) const int x = (gnScreenWidth - w) / 2; const UiFlags style = pItem->enabled() ? UiFlags::ColorGold : UiFlags::ColorBlack; - DrawString(out, _(pItem->pszStr), Point { x, y }, + DrawString(out, menuText, Point { x, y }, { .flags = style | UiFlags::FontSize46, .spacing = 2 }); if (pItem == sgpCurrItem) { const ClxSprite sprite = (*PentSpin_cel)[PentSpn2Spin()]; diff --git a/Source/inv.cpp b/Source/inv.cpp index fc97994c9..202ecd6e7 100644 --- a/Source/inv.cpp +++ b/Source/inv.cpp @@ -22,6 +22,7 @@ #include "controls/control_mode.hpp" #include "controls/plrctrls.h" #include "cursor.h" +#include "diablo.h" #include "engine/backbuffer_state.hpp" #include "engine/clx_sprite.hpp" #include "engine/load_cel.hpp" @@ -1691,8 +1692,12 @@ void InvGetItem(Player &player, int ii) NewCursor(player.HoldItem); } + const bool pickedUniqueItem = &player == MyPlayer && item._iMagical == ITEM_QUALITY_UNIQUE; + // This potentially moves items in memory so must be done after we've made a copy CleanupItems(ii); + if (pickedUniqueItem) + RequestAutoSave(AutoSaveReason::UniquePickup); pcursitem = -1; } @@ -1771,7 +1776,10 @@ void AutoGetItem(Player &player, Item *itemPointer, int ii) PlaySFX(SfxID::GrabItem); } + const bool pickedUniqueItem = &player == MyPlayer && item._iMagical == ITEM_QUALITY_UNIQUE; CleanupItems(ii); + if (pickedUniqueItem) + RequestAutoSave(AutoSaveReason::UniquePickup); return; } diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 010658713..5043df630 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -57,6 +57,41 @@ constexpr size_t PlayerWalkPathSizeForSaveGame = 25; uint8_t giNumberQuests; uint8_t giNumberOfSmithPremiumItems; +bool ActiveSaveContainsGame() +{ + if (gbIsMultiplayer) + return false; + + auto archive = OpenSaveArchive(gSaveNumber); + if (!archive) + return false; + + auto gameData = ReadArchive(*archive, "game"); + if (gameData == nullptr) + return false; + + return IsHeaderValid(LoadLE32(gameData.get())); +} + +bool ActiveSaveContainsStash() +{ + auto archive = OpenStashArchive(); + if (!archive) + return true; + + const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; + return ReadArchive(*archive, stashFileName) != nullptr; +} + +SaveResult GetSaveFailureResult() +{ + const bool hasValidGame = ActiveSaveContainsGame(); + const bool hasValidStash = ActiveSaveContainsStash(); + + gbValidSaveFile = hasValidGame; + return (hasValidGame && hasValidStash) ? SaveResult::FailedButPreviousSavePreserved : SaveResult::FailedNoValidSave; +} + template T SwapLE(T in) { @@ -2680,7 +2715,7 @@ tl::expected LoadGame(bool firstflag) gbProcessPlayers = IsDiabloAlive(!firstflag); if (gbIsHellfireSaveGame != gbIsHellfire) { - SaveGame(); + SaveGame(SaveKind::System); } gbIsHellfireSaveGame = gbIsHellfire; @@ -2926,11 +2961,41 @@ void SaveGameData(SaveWriter &saveWriter) SaveLevelSeeds(saveWriter); } -void SaveGame() +SaveResult SaveGame(SaveKind kind) { - gbValidSaveFile = true; +#if defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM) pfile_write_hero(/*writeGameData=*/true); sfile_write_stash(); + gbValidSaveFile = true; + return SaveResult::Success; +#else + switch (kind) { + case SaveKind::Manual: + if (!pfile_write_manual_game_with_backup()) + return GetSaveFailureResult(); + break; + case SaveKind::Auto: + case SaveKind::System: + if (!pfile_write_auto_game()) + return GetSaveFailureResult(); + break; + } + + gbValidSaveFile = true; + switch (kind) { + case SaveKind::Manual: + if (!pfile_write_manual_stash_with_backup()) + return GetSaveFailureResult(); + break; + case SaveKind::Auto: + case SaveKind::System: + if (!pfile_write_auto_stash()) + return GetSaveFailureResult(); + break; + } + + return SaveResult::Success; +#endif } void SaveLevel(SaveWriter &saveWriter) diff --git a/Source/loadsave.h b/Source/loadsave.h index 0d139e143..071dffb17 100644 --- a/Source/loadsave.h +++ b/Source/loadsave.h @@ -18,6 +18,18 @@ namespace devilution { extern DVL_API_FOR_TEST bool gbIsHellfireSaveGame; extern DVL_API_FOR_TEST uint8_t giNumberOfLevels; +enum class SaveKind : uint8_t { + Manual, + Auto, + System, +}; + +enum class SaveResult : uint8_t { + Success, + FailedButPreviousSavePreserved, + FailedNoValidSave, +}; + void RemoveInvalidItem(Item &pItem); _item_indexes RemapItemIdxFromDiablo(_item_indexes i); _item_indexes RemapItemIdxToDiablo(_item_indexes i); @@ -40,7 +52,7 @@ tl::expected LoadGame(bool firstflag); void SaveHotkeys(SaveWriter &saveWriter, const Player &player); void SaveHeroItems(SaveWriter &saveWriter, Player &player); void SaveGameData(SaveWriter &saveWriter); -void SaveGame(); +SaveResult SaveGame(SaveKind kind); void SaveLevel(SaveWriter &saveWriter); tl::expected LoadLevel(); tl::expected ConvertLevels(SaveWriter &saveWriter); diff --git a/Source/monster.cpp b/Source/monster.cpp index e8905a23d..5a935cda0 100644 --- a/Source/monster.cpp +++ b/Source/monster.cpp @@ -4025,6 +4025,8 @@ void MonsterDeath(Monster &monster, Direction md, bool sendmsg) M_ClearSquares(monster); monster.occupyTile(monster.position.tile, false); CheckQuestKill(monster, sendmsg); + if (!gbIsMultiplayer && IsAnyOf(monster.type().type, MT_CLEAVER, MT_SKING, MT_DIABLO, MT_DEFILER, MT_NAKRUL)) + RequestAutoSave(AutoSaveReason::BossKill); M_FallenFear(monster.position.tile); if (IsAnyOf(monster.type().type, MT_NACID, MT_RACID, MT_BACID, MT_XACID, MT_SPIDLORD)) AddMissile(monster.position.tile, { 0, 0 }, Direction::South, MissileID::AcidPuddle, TARGET_PLAYERS, monster, monster.intelligence + 1, 0); diff --git a/Source/options.cpp b/Source/options.cpp index 52e2730ed..5a8a91439 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -865,6 +865,12 @@ GameplayOptions::GameplayOptions() , autoRefillBelt("Auto Refill Belt", OptionEntryFlags::None, N_("Auto Refill Belt"), N_("Refill belt from inventory when belt item is consumed."), false) , disableCripplingShrines("Disable Crippling Shrines", OptionEntryFlags::None, N_("Disable Crippling Shrines"), N_("When enabled Cauldrons, Fascinating Shrines, Goat Shrines, Ornate Shrines, Sacred Shrines and Murphy's Shrines are not able to be clicked on and labeled as disabled."), false) , quickCast("Quick Cast", OptionEntryFlags::None, N_("Quick Cast"), N_("Spell hotkeys instantly cast the spell, rather than switching the readied spell."), false) + , autoSaveEnabled("Auto Save", OptionEntryFlags::CantChangeInMultiPlayer, N_("Auto Save"), N_("Autosave works only in single player and only at safe moments."), false) +#ifdef _DEBUG + , autoSaveIntervalSeconds("Auto Save Interval", OptionEntryFlags::CantChangeInMultiPlayer, N_("Autosave interval (seconds)"), N_("Time between periodic autosave attempts."), 120, { 30, 60, 90, 120, 180, 300, 600 }) +#else + , autoSaveIntervalSeconds("Auto Save Interval", OptionEntryFlags::CantChangeInMultiPlayer | OptionEntryFlags::Invisible, "", "", 120, { 30, 60, 90, 120, 180, 300, 600 }) +#endif , numHealPotionPickup("Heal Potion Pickup", OptionEntryFlags::None, N_("Heal Potion Pickup"), N_("Number of Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numFullHealPotionPickup("Full Heal Potion Pickup", OptionEntryFlags::None, N_("Full Heal Potion Pickup"), N_("Number of Full Healing potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) , numManaPotionPickup("Mana Potion Pickup", OptionEntryFlags::None, N_("Mana Potion Pickup"), N_("Number of Mana potions to pick up automatically."), 0, { 0, 1, 2, 4, 8, 16 }) @@ -886,6 +892,10 @@ std::vector GameplayOptions::GetEntries() &cowQuest, &runInTown, &quickCast, + &autoSaveEnabled, +#ifdef _DEBUG + &autoSaveIntervalSeconds, +#endif &testBard, &testBarbarian, &experienceBar, diff --git a/Source/options.h b/Source/options.h index 58954f55f..41e00dbf0 100644 --- a/Source/options.h +++ b/Source/options.h @@ -623,6 +623,10 @@ struct GameplayOptions : OptionCategoryBase { OptionEntryBoolean disableCripplingShrines; /** @brief Spell hotkeys instantly cast the spell. */ OptionEntryBoolean quickCast; + /** @brief Enable periodic and event-driven automatic save in single player. */ + OptionEntryBoolean autoSaveEnabled; + /** @brief Time in seconds between automatic save attempts. */ + OptionEntryInt autoSaveIntervalSeconds; /** @brief Number of Healing potions to pick up automatically */ OptionEntryInt numHealPotionPickup; /** @brief Number of Full Healing potions to pick up automatically */ diff --git a/Source/pfile.cpp b/Source/pfile.cpp index ec96c6608..a77dc0e07 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -76,9 +76,9 @@ std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {}) ); } -std::string GetStashSavePath() +std::string GetStashSavePath(std::string_view savePrefix = {}) { - return StrCat(paths::PrefPath(), + return StrCat(paths::PrefPath(), savePrefix, gbIsSpawn ? "stash_spawn" : "stash", #ifdef UNPACKED_SAVES gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR @@ -170,22 +170,57 @@ SaveWriter GetStashWriter() return SaveWriter(GetStashSavePath()); } -#ifndef DISABLE_DEMOMODE -void CopySaveFile(uint32_t saveNum, std::string targetPath) +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) +bool SaveLocationExists(const std::string &location) +{ + return FileExists(location.c_str()) || DirectoryExists(location.c_str()); +} + +void CopySaveLocation(const std::string &sourceLocation, const std::string &targetLocation) { - const std::string savePath = GetSavePath(saveNum); #if defined(UNPACKED_SAVES) -#ifdef DVL_NO_FILESYSTEM -#error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " + if (!targetLocation.empty()) { + CreateDir(targetLocation.c_str()); + } + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(sourceLocation)) { + const std::filesystem::path targetFilePath = std::filesystem::path(targetLocation) / entry.path().filename(); + CopyFileOverwrite(entry.path().string().c_str(), targetFilePath.string().c_str()); + } +#else + CopyFileOverwrite(sourceLocation.c_str(), targetLocation.c_str()); #endif - if (!targetPath.empty()) { - CreateDir(targetPath.c_str()); +} + +void RestoreSaveLocation(const std::string &targetLocation, const std::string &backupLocation) +{ +#if defined(UNPACKED_SAVES) + if (DirectoryExists(targetLocation.c_str())) { + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(targetLocation)) + RemoveFile(entry.path().string().c_str()); } - for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(savePath)) { - CopyFileOverwrite(entry.path().string().c_str(), (targetPath + entry.path().filename().string()).c_str()); + CreateDir(targetLocation.c_str()); + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(backupLocation)) { + const std::filesystem::path restoredFilePath = std::filesystem::path(targetLocation) / entry.path().filename(); + CopyFileOverwrite(entry.path().string().c_str(), restoredFilePath.string().c_str()); } #else - CopyFileOverwrite(savePath.c_str(), targetPath.c_str()); + CopyFileOverwrite(backupLocation.c_str(), targetLocation.c_str()); +#endif +} + +void DeleteSaveLocation(const std::string &location) +{ +#if defined(UNPACKED_SAVES) + if (!DirectoryExists(location.c_str())) + return; + + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(location)) + RemoveFile(entry.path().string().c_str()); + + std::filesystem::remove(location); +#else + if (FileExists(location.c_str())) + RemoveFile(location.c_str()); #endif } #endif @@ -629,12 +664,118 @@ void pfile_write_hero(bool writeGameData) pfile_write_hero(saveWriter, writeGameData); } +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) +bool SaveWrittenGameIsValid() +{ + auto archive = OpenSaveArchive(gSaveNumber); + return archive && ArchiveContainsGame(*archive); +} + +bool WriteGameAndRestoreOnFailure(const std::string &restoreLocation) +{ + const bool hasRestoreLocation = SaveLocationExists(restoreLocation); + + pfile_write_hero(/*writeGameData=*/true); + + if (SaveWrittenGameIsValid()) + return true; + + if (!hasRestoreLocation) + return false; + + RestoreSaveLocation(GetSavePath(gSaveNumber), restoreLocation); + return false; +} + +bool pfile_write_manual_game_with_backup() +{ + const std::string backupPrefix = "backup_"; + const std::string backupLocation = GetSavePath(gSaveNumber, backupPrefix); + const std::string saveLocation = GetSavePath(gSaveNumber); + + if (SaveLocationExists(saveLocation)) + CopySaveLocation(saveLocation, backupLocation); + + return WriteGameAndRestoreOnFailure(backupLocation); +} + +bool pfile_write_auto_game() +{ + const std::string restorePrefix = "autosave_restore_"; + const std::string restoreLocation = GetSavePath(gSaveNumber, restorePrefix); + const std::string saveLocation = GetSavePath(gSaveNumber); + + if (SaveLocationExists(saveLocation)) + CopySaveLocation(saveLocation, restoreLocation); + + const bool saveIsValid = WriteGameAndRestoreOnFailure(restoreLocation); + DeleteSaveLocation(restoreLocation); + return saveIsValid; +} + +bool WriteStashAndRestoreOnFailure(std::string_view restorePrefix, bool deleteRestoreLocationOnSuccess) +{ + if (!Stash.dirty) + return true; + + const std::string restoreLocation = GetStashSavePath(restorePrefix); + const std::string stashLocation = GetStashSavePath(); + + SaveWriter stashWriter = GetStashWriter(); + SaveStash(stashWriter); + + auto archive = OpenStashArchive(); + const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; + const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; + if (stashIsValid) { + Stash.dirty = false; + if (deleteRestoreLocationOnSuccess) + DeleteSaveLocation(restoreLocation); + return true; + } + + if (!SaveLocationExists(restoreLocation)) + return false; + + RestoreSaveLocation(stashLocation, restoreLocation); + return false; +} + +bool pfile_write_manual_stash_with_backup() +{ + const std::string backupPrefix = "backup_"; + const std::string backupLocation = GetStashSavePath(backupPrefix); + const std::string stashLocation = GetStashSavePath(); + + if (SaveLocationExists(stashLocation)) + CopySaveLocation(stashLocation, backupLocation); + + return WriteStashAndRestoreOnFailure(backupPrefix, false); +} + +bool pfile_write_auto_stash() +{ + const std::string restorePrefix = "autosave_restore_"; + const std::string restoreLocation = GetStashSavePath(restorePrefix); + const std::string stashLocation = GetStashSavePath(); + + if (SaveLocationExists(stashLocation)) + CopySaveLocation(stashLocation, restoreLocation); + + const bool stashIsValid = WriteStashAndRestoreOnFailure(restorePrefix, true); + if (!stashIsValid && SaveLocationExists(restoreLocation)) + DeleteSaveLocation(restoreLocation); + return stashIsValid; +} +#endif + #ifndef DISABLE_DEMOMODE +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) void pfile_write_hero_demo(int demo) { - const std::string savePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_reference_")); - CopySaveFile(gSaveNumber, savePath); - auto saveWriter = SaveWriter(savePath.c_str()); + const std::string saveLocation = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_reference_")); + CopySaveLocation(GetSavePath(gSaveNumber), saveLocation); + auto saveWriter = SaveWriter(saveLocation.c_str()); pfile_write_hero(saveWriter, true); } @@ -647,13 +788,27 @@ HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) const std::string actualSavePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_actual_")); { - CopySaveFile(gSaveNumber, actualSavePath); + CopySaveLocation(GetSavePath(gSaveNumber), actualSavePath); SaveWriter saveWriter(actualSavePath.c_str()); pfile_write_hero(saveWriter, true); } return CompareSaves(actualSavePath, referenceSavePath, logDetails); } +#else +// Demo save comparison is unavailable on UNPACKED_SAVES targets without filesystem support. +void pfile_write_hero_demo(int demo) +{ + (void)demo; +} + +HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) +{ + (void)demo; + (void)logDetails; + return { HeroCompareResult::ReferenceNotFound, {} }; +} +#endif #endif void sfile_write_stash() diff --git a/Source/pfile.h b/Source/pfile.h index df10fca08..d41c00d44 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -101,6 +101,10 @@ std::optional OpenStashArchive(); const char *pfile_get_password(); std::unique_ptr ReadArchive(SaveReader &archive, const char *pszName, size_t *pdwLen = nullptr); void pfile_write_hero(bool writeGameData = false); +bool pfile_write_manual_game_with_backup(); +bool pfile_write_auto_game(); +bool pfile_write_manual_stash_with_backup(); +bool pfile_write_auto_stash(); #ifndef DISABLE_DEMOMODE /** diff --git a/Source/player.cpp b/Source/player.cpp index 1b6f14096..ba21a1fc9 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -24,6 +24,7 @@ #ifdef _DEBUG #include "debug.h" #endif +#include "diablo.h" #include "engine/backbuffer_state.hpp" #include "engine/load_cl2.hpp" #include "engine/load_file.hpp" @@ -179,6 +180,9 @@ void StartAttack(Player &player, Direction d, bool includesFirstFrame) return; } + if (&player == MyPlayer && IsAnyOf(LastPlayerAction, PlayerActionType::AttackMonsterTarget, PlayerActionType::AttackPlayerTarget)) + MarkCombatActivity(); + int8_t skippedAnimationFrames = 0; const auto flags = player._pIFlags; @@ -211,6 +215,9 @@ void StartRangeAttack(Player &player, Direction d, WorldTileCoord cx, WorldTileC return; } + if (&player == MyPlayer) + MarkCombatActivity(); + int8_t skippedAnimationFrames = 0; const auto flags = player._pIFlags; @@ -246,6 +253,17 @@ player_graphic GetPlayerGraphicForSpell(SpellID spellId) } } +bool IsSpellOffensiveForAutoSave(SpellID spellId) +{ + const SpellData &spellData = GetSpellData(spellId); + for (MissileID missileId : spellData.sMissiles) { + if (missileId != MissileID::Null) + return true; + } + + return false; +} + void StartSpell(Player &player, Direction d, WorldTileCoord cx, WorldTileCoord cy) { if (player._pInvincible && player.hasNoLife() && &player == MyPlayer) { @@ -272,6 +290,9 @@ void StartSpell(Player &player, Direction d, WorldTileCoord cx, WorldTileCoord c if (!isValid) return; + if (&player == MyPlayer && IsSpellOffensiveForAutoSave(player.queuedSpell.spellId)) + MarkCombatActivity(); + auto animationFlags = AnimationDistributionFlags::ProcessAnimationPending; if (player._pmode == PM_SPELL) animationFlags = static_cast(animationFlags | AnimationDistributionFlags::RepeatedAction); @@ -761,6 +782,8 @@ bool PlrHitPlr(Player &attacker, Player &target) if (&attacker == MyPlayer) { NetSendCmdDamage(true, target, skdam, DamageType::Physical); } + if (&attacker == MyPlayer && skdam > 0) + MarkCombatActivity(); StartPlrHit(target, skdam, false); return true; @@ -2823,6 +2846,8 @@ void StripTopGold(Player &player) void ApplyPlrDamage(DamageType damageType, Player &player, int dam, int minHP /*= 0*/, int frac /*= 0*/, DeathReason deathReason /*= DeathReason::MonsterOrTrap*/) { int totalDamage = (dam << 6) + frac; + if (&player == MyPlayer && totalDamage > 0) + MarkCombatActivity(); if (&player == MyPlayer && !player.hasNoLife()) { lua::OnPlayerTakeDamage(&player, totalDamage, static_cast(damageType)); } diff --git a/Source/utils/sdl2_backports.h b/Source/utils/sdl2_backports.h index f1c3f584d..cefc8a223 100644 --- a/Source/utils/sdl2_backports.h +++ b/Source/utils/sdl2_backports.h @@ -7,6 +7,10 @@ #define SDL_MAX_UINT32 ((Uint32)0xFFFFFFFFu) #endif +#ifndef SDL_TICKS_PASSED +#define SDL_TICKS_PASSED(A, B) ((Sint32)((B) - (A)) <= 0) +#endif + #if !SDL_VERSION_ATLEAST(2, 0, 4) inline SDL_bool SDL_PointInRect(const SDL_Point *p, const SDL_Rect *r) { diff --git a/Source/utils/sdl2_to_1_2_backports.h b/Source/utils/sdl2_to_1_2_backports.h index 2a18cca66..5e13df332 100644 --- a/Source/utils/sdl2_to_1_2_backports.h +++ b/Source/utils/sdl2_to_1_2_backports.h @@ -26,6 +26,7 @@ #define SDL_floor floor #define SDL_MAX_UINT32 ((Uint32)0xFFFFFFFFu) +#define SDL_TICKS_PASSED(A, B) ((Sint32)((B) - (A)) <= 0) //== Events handling