From 80ecc31151a87597410d26ef347c77b9bd65ecbd Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:02:12 +0100 Subject: [PATCH 01/20] Add autosave with backup and UI updates Implement an autosave subsystem and safer save handling, plus related UI and hooks. - Add autosave state and logic (diablo.cpp): periodic timer, pending queue, priorities (Timer, TownEntry, BossKill, UniquePickup), cooldowns, combat cooldowns, enemy proximity safety checks, and helper APIs (QueueAutoSave, AttemptAutoSave, IsAutoSaveSafe, etc.). - Integrate autosave triggers: queue on town entry (loadsave), unique item pickup (inv.cpp), boss kills (monster.cpp), and mark combat activity from player actions and hits (player.cpp). - Add gameplay options to enable autosave and set interval (options.h/.cpp) and display countdown/ready label in the game menu (gamemenu.cpp/gmenu.cpp). Menu text retrieval updated to show remaining seconds or "ready". - Make SaveGame robust (loadsave.cpp): write hero and stash via new pfile_write_hero_with_backup() and pfile_write_stash_with_backup() that create backups and restore on failure. Add utilities to copy/restore unpacked save directories safely (pfile.cpp) and adjust stash path handling signature. - Minor fixes and cleanups: restrict mouse-motion handling to KeyboardAndMouse path, small reordering in player sprite width switch, and a few safety/formatting tweaks. Autosave only runs in single-player and when IsAutoSaveSafe() conditions are met. Backup save logic attempts to preserve the previous save on failure. --- Source/diablo.cpp | 202 +++++++++++++++++++++++++++++++++++++++++++- Source/diablo.h | 14 +++ Source/gamemenu.cpp | 31 +++++++ Source/gamemenu.h | 5 ++ Source/gmenu.cpp | 6 +- Source/inv.cpp | 22 +++-- Source/loadsave.cpp | 20 +++-- Source/monster.cpp | 6 +- Source/options.cpp | 4 + Source/options.h | 4 + Source/pfile.cpp | 129 ++++++++++++++++++++++------ Source/pfile.h | 4 +- Source/player.cpp | 30 ++++++- 13 files changed, 429 insertions(+), 48 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b14da41cb..cfca7ea35 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,56 @@ bool was_archives_init = false; /** To know if surfaces have been initialized or not */ bool was_window_init = false; bool was_ui_init = false; +uint32_t autoSaveNextTimerDueAt = 0; +AutoSaveReason pendingAutoSaveReason = AutoSaveReason::None; +/** Prevent autosave from running immediately after session start before player interaction. */ +bool hasEnteredActiveGameplay = false; +uint32_t autoSaveCooldownUntil = 0; +uint32_t autoSaveCombatCooldownUntil = 0; +constexpr uint32_t AutoSaveCooldownMilliseconds = 5000; +constexpr uint32_t AutoSaveCombatCooldownMilliseconds = 4000; +constexpr int AutoSaveEnemyProximityTiles = 6; + +uint32_t 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 +245,11 @@ 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(); } void FreeGame() @@ -775,9 +831,11 @@ void GameEventHandler(const SDL_Event &event, uint16_t modState) ReleaseKey(SDLC_EventKey(event)); return; case SDL_EVENT_MOUSE_MOTION: - if (ControlMode == ControlTypes::KeyboardAndMouse && invflag) - InvalidateInventorySlot(); - MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; + if (ControlMode == ControlTypes::KeyboardAndMouse) { + if (invflag) + InvalidateInventorySlot(); + MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; + } gmenu_on_mouse_move(); return; case SDL_EVENT_MOUSE_BUTTON_DOWN: @@ -1553,6 +1611,26 @@ void GameLogic() RedrawViewport(); pfile_update(false); + if (!hasEnteredActiveGameplay && LastPlayerAction != PlayerActionType::None) + hasEnteredActiveGameplay = true; + + if (*GetOptions().Gameplay.autoSaveEnabled) { + const uint32_t now = SDL_GetTicks(); + if (SDL_TICKS_PASSED(now, autoSaveNextTimerDueAt)) { + QueueAutoSave(AutoSaveReason::Timer); + } + } else { + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + pendingAutoSaveReason = AutoSaveReason::None; + } + + if (pendingAutoSaveReason != AutoSaveReason::None && IsAutoSaveSafe()) { + if (AttemptAutoSave(pendingAutoSaveReason)) { + pendingAutoSaveReason = AutoSaveReason::None; + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + } + } + plrctrls_after_game_logic(); } @@ -1802,6 +1880,122 @@ const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueCha } // namespace +bool IsEnemyTooCloseForAutoSave(); + +bool IsAutoSaveSafe() +{ + if (gbIsMultiplayer || !gbRunGame) + return false; + + if (!hasEnteredActiveGameplay) + return false; + + if (!SDL_TICKS_PASSED(SDL_GetTicks(), autoSaveCooldownUntil)) + return false; + + if (!SDL_TICKS_PASSED(SDL_GetTicks(), 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 && IsEnemyTooCloseForAutoSave()) + return false; + + return true; +} + +void MarkCombatActivity() +{ + autoSaveCombatCooldownUntil = SDL_GetTicks() + AutoSaveCombatCooldownMilliseconds; +} + +bool IsEnemyTooCloseForAutoSave() +{ + if (MyPlayer == nullptr) + return false; + + const Point playerPosition = MyPlayer->position.tile; + for (size_t i = 0; i < ActiveMonsterCount; i++) { + const Monster &monster = Monsters[ActiveMonsters[i]]; + if (monster.hitPoints <= 0 || monster.mode == MonsterMode::Death || monster.mode == MonsterMode::Petrified) + continue; + + if (monster.type().type == MT_GOLEM) + continue; + + if ((monster.flags & MFLAG_HIDDEN) != 0) + continue; + + const int distance = std::max( + std::abs(monster.position.tile.x - playerPosition.x), + std::abs(monster.position.tile.y - playerPosition.y)); + if (distance <= AutoSaveEnemyProximityTiles) + return true; + } + + return false; +} + +int GetSecondsUntilNextAutoSave() +{ + if (!*GetOptions().Gameplay.autoSaveEnabled) + return -1; + + if (IsAutoSavePending()) + return 0; + + const uint32_t now = SDL_GetTicks(); + if (SDL_TICKS_PASSED(now, autoSaveNextTimerDueAt)) + return 0; + + const uint32_t remainingMilliseconds = autoSaveNextTimerDueAt - now; + return static_cast((remainingMilliseconds + 999) / 1000); +} + +bool IsAutoSavePending() +{ + return pendingAutoSaveReason != AutoSaveReason::None; +} + +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 uint32_t currentTime = SDL_GetTicks(); + SaveGame(); + autoSaveCooldownUntil = currentTime + AutoSaveCooldownMilliseconds; + if (gbValidSaveFile) { + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + if (reason != AutoSaveReason::Timer) + InitDiabloMsg(EMSG_GAME_SAVED, currentTime + 1000 - SDL_GetTicks()); + } + SetEventHandler(saveProc); + return gbValidSaveFile; +} + void InitKeymapActions() { Options &options = GetOptions(); @@ -3434,6 +3628,8 @@ tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) CompleteProgress(); LoadGameLevelCalculateCursor(); + if (leveltype == DTYPE_TOWN && lvldir != ENTRY_LOAD && !firstflag) + ::devilution::QueueAutoSave(AutoSaveReason::TownEntry); return {}; } diff --git a/Source/diablo.h b/Source/diablo.h index ad28ebb82..539211a61 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,12 @@ 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 IsAutoSavePending(); +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..64ba95eaf 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" @@ -89,6 +93,8 @@ const char *const SoundToggleNames[] = { N_("Sound Disabled"), }; +std::string saveGameMenuLabel; + void GamemenuUpdateSingle() { sgSingleMenu[2].setEnabled(gbValidSaveFile); @@ -98,6 +104,23 @@ void GamemenuUpdateSingle() sgSingleMenu[0].setEnabled(enable); } +const char *GetSaveGameMenuLabel() +{ + if (IsAutoSavePending()) { + saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:s})")), _("ready")); + return saveGameMenuLabel.c_str(); + } + + const int seconds = GetSecondsUntilNextAutoSave(); + if (seconds < 0) { + saveGameMenuLabel = _("Save Game"); + return saveGameMenuLabel.c_str(); + } + + saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); + return saveGameMenuLabel.c_str(); +} + void GamemenuPrevious(bool /*bActivate*/) { gamemenu_on(); @@ -363,6 +386,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..528f670db 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,10 +1692,14 @@ void InvGetItem(Player &player, int ii) NewCursor(player.HoldItem); } - // This potentially moves items in memory so must be done after we've made a copy - CleanupItems(ii); - pcursitem = -1; -} + 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) + QueueAutoSave(AutoSaveReason::UniquePickup); + pcursitem = -1; +} std::optional FindAdjacentPositionForItem(Point origin, Direction facing) { @@ -1771,9 +1776,12 @@ void AutoGetItem(Player &player, Item *itemPointer, int ii) PlaySFX(SfxID::GrabItem); } - CleanupItems(ii); - return; - } + const bool pickedUniqueItem = &player == MyPlayer && item._iMagical == ITEM_QUALITY_UNIQUE; + CleanupItems(ii); + if (pickedUniqueItem) + QueueAutoSave(AutoSaveReason::UniquePickup); + return; + } if (&player == MyPlayer) { player.Say(HeroSpeech::ICantCarryAnymore); diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 010658713..30b10c72b 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -2926,12 +2926,20 @@ void SaveGameData(SaveWriter &saveWriter) SaveLevelSeeds(saveWriter); } -void SaveGame() -{ - gbValidSaveFile = true; - pfile_write_hero(/*writeGameData=*/true); - sfile_write_stash(); -} +void SaveGame() +{ + gbValidSaveFile = true; + const bool heroSaved = pfile_write_hero_with_backup(/*writeGameData=*/true); + if (!heroSaved) { + gbValidSaveFile = false; + return; + } + + if (!pfile_write_stash_with_backup()) { + gbValidSaveFile = false; + return; + } +} void SaveLevel(SaveWriter &saveWriter) { diff --git a/Source/monster.cpp b/Source/monster.cpp index e8905a23d..0463e6581 100644 --- a/Source/monster.cpp +++ b/Source/monster.cpp @@ -4024,8 +4024,10 @@ void MonsterDeath(Monster &monster, Direction md, bool sendmsg) monster.position.future = monster.position.old; M_ClearSquares(monster); monster.occupyTile(monster.position.tile, false); - CheckQuestKill(monster, sendmsg); - M_FallenFear(monster.position.tile); + CheckQuestKill(monster, sendmsg); + if (!gbIsMultiplayer && IsAnyOf(monster.type().type, MT_CLEAVER, MT_SKING, MT_DIABLO, MT_DEFILER, MT_NAKRUL)) + QueueAutoSave(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..ca249b2ba 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -865,6 +865,8 @@ 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) + , autoSaveIntervalSeconds("Auto Save Interval", OptionEntryFlags::CantChangeInMultiPlayer, N_("Autosave interval (seconds)"), N_("Time between periodic autosave attempts."), 120, { 30, 60, 90, 120, 180, 300, 600 }) , 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 +888,8 @@ std::vector GameplayOptions::GetEntries() &cowQuest, &runInTown, &quickCast, + &autoSaveEnabled, + &autoSaveIntervalSeconds, &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..4d3af3006 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -76,12 +76,12 @@ std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {}) ); } -std::string GetStashSavePath() -{ - return StrCat(paths::PrefPath(), - gbIsSpawn ? "stash_spawn" : "stash", -#ifdef UNPACKED_SAVES - gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR +std::string GetStashSavePath(std::string_view savePrefix = {}) +{ + return StrCat(paths::PrefPath(), savePrefix, + gbIsSpawn ? "stash_spawn" : "stash", +#ifdef UNPACKED_SAVES + gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR #else gbIsHellfire ? ".hsv" : ".sv" #endif @@ -170,25 +170,45 @@ SaveWriter GetStashWriter() return SaveWriter(GetStashSavePath()); } -#ifndef DISABLE_DEMOMODE -void CopySaveFile(uint32_t saveNum, std::string targetPath) -{ - const std::string savePath = GetSavePath(saveNum); -#if defined(UNPACKED_SAVES) -#ifdef DVL_NO_FILESYSTEM -#error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " -#endif - if (!targetPath.empty()) { - CreateDir(targetPath.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()); - } -#else - CopyFileOverwrite(savePath.c_str(), targetPath.c_str()); -#endif -} -#endif +void CopySaveFile(uint32_t saveNum, std::string targetPath) +{ + const std::string savePath = GetSavePath(saveNum); + +#if defined(UNPACKED_SAVES) +#ifdef DVL_NO_FILESYSTEM +#error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " +#endif + if (!targetPath.empty()) { + CreateDir(targetPath.c_str()); + } + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(savePath)) { + const std::filesystem::path targetFilePath = std::filesystem::path(targetPath) / entry.path().filename(); + CopyFileOverwrite(entry.path().string().c_str(), targetFilePath.string().c_str()); + } +#else + CopyFileOverwrite(savePath.c_str(), targetPath.c_str()); +#endif +} + +void RestoreSaveFile(const std::string &targetPath, const std::string &backupPath) +{ +#if defined(UNPACKED_SAVES) +#ifdef DVL_NO_FILESYSTEM +#error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " +#endif + if (DirectoryExists(targetPath.c_str())) { + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(targetPath)) + RemoveFile(entry.path().string().c_str()); + } + CreateDir(targetPath.c_str()); + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(backupPath)) { + const std::filesystem::path restoredFilePath = std::filesystem::path(targetPath) / entry.path().filename(); + CopyFileOverwrite(entry.path().string().c_str(), restoredFilePath.string().c_str()); + } +#else + CopyFileOverwrite(backupPath.c_str(), targetPath.c_str()); +#endif +} void Game2UiPlayer(const Player &player, _uiheroinfo *heroinfo, bool bHasSaveFile) { @@ -629,6 +649,65 @@ void pfile_write_hero(bool writeGameData) pfile_write_hero(saveWriter, writeGameData); } +bool pfile_write_hero_with_backup(bool writeGameData) +{ + const std::string backupPrefix = "backup_"; + const std::string backupPath = GetSavePath(gSaveNumber, backupPrefix); + const std::string savePath = GetSavePath(gSaveNumber); + + if (FileExists(savePath) || DirectoryExists(savePath.c_str())) + CopySaveFile(gSaveNumber, backupPath); + + pfile_write_hero(writeGameData); + + auto archive = OpenSaveArchive(gSaveNumber); + const bool saveIsValid = archive && ArchiveContainsGame(*archive); + if (saveIsValid || !(FileExists(backupPath) || DirectoryExists(backupPath.c_str()))) + return saveIsValid; + + RestoreSaveFile(savePath, backupPath); + + return false; +} + +bool pfile_write_stash_with_backup() +{ + if (!Stash.dirty) + return true; + + const std::string backupPrefix = "backup_"; + const std::string backupPath = GetStashSavePath(backupPrefix); + const std::string stashPath = GetStashSavePath(); + + if (FileExists(stashPath) || DirectoryExists(stashPath.c_str())) { +#if defined(UNPACKED_SAVES) + CreateDir(backupPath.c_str()); + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(stashPath)) { + const std::filesystem::path targetFilePath = std::filesystem::path(backupPath) / entry.path().filename(); + CopyFileOverwrite(entry.path().string().c_str(), targetFilePath.string().c_str()); + } +#else + CopyFileOverwrite(stashPath.c_str(), backupPath.c_str()); +#endif + } + + SaveWriter stashWriter = GetStashWriter(); + SaveStash(stashWriter); + + auto archive = OpenStashArchive(); + const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; + const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; + if (stashIsValid || !(FileExists(backupPath) || DirectoryExists(backupPath.c_str()))) { + if (stashIsValid) + Stash.dirty = false; + return stashIsValid; + } + + RestoreSaveFile(stashPath, backupPath); + + return false; +} + #ifndef DISABLE_DEMOMODE void pfile_write_hero_demo(int demo) { diff --git a/Source/pfile.h b/Source/pfile.h index df10fca08..2e1b54f7c 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -101,6 +101,8 @@ 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_hero_with_backup(bool writeGameData = false); +bool pfile_write_stash_with_backup(); #ifndef DISABLE_DEMOMODE /** @@ -117,7 +119,7 @@ void pfile_write_hero_demo(int demo); HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails); #endif -void sfile_write_stash(); +void sfile_write_stash(); bool pfile_ui_set_hero_infos(bool (*uiAddHeroInfo)(_uiheroinfo *)); void pfile_ui_set_class_stats(HeroClass playerClass, _uidefaultstats *classStats); uint32_t pfile_ui_get_first_unused_save_num(); diff --git a/Source/player.cpp b/Source/player.cpp index 1b6f14096..4c3d95e42 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; @@ -199,6 +203,7 @@ void StartAttack(Player &player, Direction d, bool includesFirstFrame) if (player._pmode == PM_ATTACK) animationFlags = static_cast(animationFlags | AnimationDistributionFlags::RepeatedAction); NewPlrAnim(player, player_graphic::Attack, d, animationFlags, skippedAnimationFrames, player._pAFNum); + player._pmode = PM_ATTACK; FixPlayerLocation(player, d); SetPlayerOld(player); @@ -211,6 +216,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 +254,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 +291,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 +783,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; @@ -1507,10 +1531,10 @@ uint16_t GetPlayerSpriteWidth(HeroClass cls, player_graphic graphic, PlayerWeapo if (weaponGraphic == PlayerWeaponGraphic::Bow) return spriteData.bow; return spriteData.attack; - case player_graphic::Hit: - return spriteData.swHit; case player_graphic::Block: return spriteData.block; + case player_graphic::Hit: + return spriteData.swHit; case player_graphic::Lightning: return spriteData.lightning; case player_graphic::Fire: @@ -2823,6 +2847,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)); } From 2f7bac0b97dfab42cbc88ce152fb41ca0d884799 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:07:15 +0100 Subject: [PATCH 02/20] Refactor autosave: centralize decision logic and improve readability - Add RequestAutoSave() wrapper function with centralized filter logic - Add HasPendingAutoSave() helper for better code readability - Replace direct QueueAutoSave() calls with RequestAutoSave() - Consolidate multiplayer and enabled checks in one place - Improve code maintainability and separation of concerns --- Source/diablo.cpp | 22 +++++++++++++++++++--- Source/diablo.h | 2 ++ Source/gamemenu.cpp | 2 +- Source/inv.cpp | 28 ++++++++++++++-------------- Source/monster.cpp | 8 ++++---- 5 files changed, 40 insertions(+), 22 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index cfca7ea35..b1b4c921f 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1624,7 +1624,7 @@ void GameLogic() pendingAutoSaveReason = AutoSaveReason::None; } - if (pendingAutoSaveReason != AutoSaveReason::None && IsAutoSaveSafe()) { + if (HasPendingAutoSave() && IsAutoSaveSafe()) { if (AttemptAutoSave(pendingAutoSaveReason)) { pendingAutoSaveReason = AutoSaveReason::None; autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); @@ -1959,11 +1959,27 @@ int GetSecondsUntilNextAutoSave() return static_cast((remainingMilliseconds + 999) / 1000); } -bool IsAutoSavePending() +bool HasPendingAutoSave() { return pendingAutoSaveReason != AutoSaveReason::None; } +void RequestAutoSave(AutoSaveReason reason) +{ + if (!*GetOptions().Gameplay.autoSaveEnabled) + return; + + if (gbIsMultiplayer) + return; + + QueueAutoSave(reason); +} + +bool IsAutoSavePending() +{ + return HasPendingAutoSave(); +} + void QueueAutoSave(AutoSaveReason reason) { if (gbIsMultiplayer) @@ -3629,7 +3645,7 @@ tl::expected LoadGameLevel(bool firstflag, lvl_entry lvldir) LoadGameLevelCalculateCursor(); if (leveltype == DTYPE_TOWN && lvldir != ENTRY_LOAD && !firstflag) - ::devilution::QueueAutoSave(AutoSaveReason::TownEntry); + ::devilution::RequestAutoSave(AutoSaveReason::TownEntry); return {}; } diff --git a/Source/diablo.h b/Source/diablo.h index 539211a61..5b47bc9c9 100644 --- a/Source/diablo.h +++ b/Source/diablo.h @@ -112,7 +112,9 @@ bool IsDiabloAlive(bool playSFX); void MarkCombatActivity(); bool IsAutoSaveSafe(); int GetSecondsUntilNextAutoSave(); +bool HasPendingAutoSave(); bool IsAutoSavePending(); +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 64ba95eaf..ce993a238 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -106,7 +106,7 @@ void GamemenuUpdateSingle() const char *GetSaveGameMenuLabel() { - if (IsAutoSavePending()) { + if (HasPendingAutoSave()) { saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:s})")), _("ready")); return saveGameMenuLabel.c_str(); } diff --git a/Source/inv.cpp b/Source/inv.cpp index 528f670db..202ecd6e7 100644 --- a/Source/inv.cpp +++ b/Source/inv.cpp @@ -1692,14 +1692,14 @@ 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) - QueueAutoSave(AutoSaveReason::UniquePickup); - pcursitem = -1; -} + 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; +} std::optional FindAdjacentPositionForItem(Point origin, Direction facing) { @@ -1776,12 +1776,12 @@ 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) - QueueAutoSave(AutoSaveReason::UniquePickup); - return; - } + const bool pickedUniqueItem = &player == MyPlayer && item._iMagical == ITEM_QUALITY_UNIQUE; + CleanupItems(ii); + if (pickedUniqueItem) + RequestAutoSave(AutoSaveReason::UniquePickup); + return; + } if (&player == MyPlayer) { player.Say(HeroSpeech::ICantCarryAnymore); diff --git a/Source/monster.cpp b/Source/monster.cpp index 0463e6581..5a935cda0 100644 --- a/Source/monster.cpp +++ b/Source/monster.cpp @@ -4024,10 +4024,10 @@ void MonsterDeath(Monster &monster, Direction md, bool sendmsg) monster.position.future = monster.position.old; 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)) - QueueAutoSave(AutoSaveReason::BossKill); - M_FallenFear(monster.position.tile); + 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); } From 06fd58823aae7f3625e9a7f0effe9a65956b8807 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:36:52 +0100 Subject: [PATCH 03/20] Stabilize autosave timing; use string_view Sample the timestamp once after SaveGame and use it to set cooldown and next-timer values to avoid inconsistent timings from multiple SDL_GetTicks calls. Calculate the saved-game message duration based on elapsed time and clamp it to a minimum of 500ms so the confirmation is visible. Also change GetSaveGameMenuLabel to return std::string_view and return the saved label directly instead of a c_str(), simplifying lifetime handling. --- Source/diablo.cpp | 13 +++++++++---- Source/gamemenu.cpp | 8 ++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index b1b4c921f..5111b0954 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -2002,11 +2002,16 @@ bool AttemptAutoSave(AutoSaveReason reason) const EventHandler saveProc = SetEventHandler(DisableInputEventHandler); const uint32_t currentTime = SDL_GetTicks(); SaveGame(); - autoSaveCooldownUntil = currentTime + AutoSaveCooldownMilliseconds; + const uint32_t afterSaveTime = SDL_GetTicks(); + + autoSaveCooldownUntil = afterSaveTime + AutoSaveCooldownMilliseconds; if (gbValidSaveFile) { - autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); - if (reason != AutoSaveReason::Timer) - InitDiabloMsg(EMSG_GAME_SAVED, currentTime + 1000 - SDL_GetTicks()); + 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 gbValidSaveFile; diff --git a/Source/gamemenu.cpp b/Source/gamemenu.cpp index ce993a238..b88e9f570 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -104,21 +104,21 @@ void GamemenuUpdateSingle() sgSingleMenu[0].setEnabled(enable); } -const char *GetSaveGameMenuLabel() +std::string_view GetSaveGameMenuLabel() { if (HasPendingAutoSave()) { saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:s})")), _("ready")); - return saveGameMenuLabel.c_str(); + return saveGameMenuLabel; } const int seconds = GetSecondsUntilNextAutoSave(); if (seconds < 0) { saveGameMenuLabel = _("Save Game"); - return saveGameMenuLabel.c_str(); + return saveGameMenuLabel; } saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); - return saveGameMenuLabel.c_str(); + return saveGameMenuLabel; } void GamemenuPrevious(bool /*bActivate*/) From 637545c238ce85b0b72c92d5ac4deec9c1ff9df3 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:31:06 +0100 Subject: [PATCH 04/20] Update player.cpp --- Source/player.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Source/player.cpp b/Source/player.cpp index 4c3d95e42..019c9a147 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -203,7 +203,6 @@ void StartAttack(Player &player, Direction d, bool includesFirstFrame) if (player._pmode == PM_ATTACK) animationFlags = static_cast(animationFlags | AnimationDistributionFlags::RepeatedAction); NewPlrAnim(player, player_graphic::Attack, d, animationFlags, skippedAnimationFrames, player._pAFNum); - player._pmode = PM_ATTACK; FixPlayerLocation(player, d); SetPlayerOld(player); @@ -1531,10 +1530,10 @@ uint16_t GetPlayerSpriteWidth(HeroClass cls, player_graphic graphic, PlayerWeapo if (weaponGraphic == PlayerWeaponGraphic::Bow) return spriteData.bow; return spriteData.attack; + case player_graphic::Hit: + return spriteData.swHit; case player_graphic::Block: return spriteData.block; - case player_graphic::Hit: - return spriteData.swHit; case player_graphic::Lightning: return spriteData.lightning; case player_graphic::Fire: From 2ac1fa143e2e59398c24e5ef6ff2d46fc9c2d80b Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:31:32 +0100 Subject: [PATCH 05/20] Clarify save location naming Rename save backup and restore helpers to use location terminology, pass source and target locations explicitly, and reuse the copy helper for stash backups. --- Source/pfile.cpp | 103 +++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 57 deletions(-) diff --git a/Source/pfile.cpp b/Source/pfile.cpp index 4d3af3006..ecf370c24 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -170,43 +170,41 @@ SaveWriter GetStashWriter() return SaveWriter(GetStashSavePath()); } -void CopySaveFile(uint32_t saveNum, std::string targetPath) +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 " #endif - if (!targetPath.empty()) { - CreateDir(targetPath.c_str()); + if (!targetLocation.empty()) { + CreateDir(targetLocation.c_str()); } - for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(savePath)) { - const std::filesystem::path targetFilePath = std::filesystem::path(targetPath) / entry.path().filename(); + 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(savePath.c_str(), targetPath.c_str()); + CopyFileOverwrite(sourceLocation.c_str(), targetLocation.c_str()); #endif } -void RestoreSaveFile(const std::string &targetPath, const std::string &backupPath) +void RestoreSaveLocation(const std::string &targetLocation, const std::string &backupLocation) { #if defined(UNPACKED_SAVES) #ifdef DVL_NO_FILESYSTEM #error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " #endif - if (DirectoryExists(targetPath.c_str())) { - for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(targetPath)) + if (DirectoryExists(targetLocation.c_str())) { + for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(targetLocation)) RemoveFile(entry.path().string().c_str()); } - CreateDir(targetPath.c_str()); - for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(backupPath)) { - const std::filesystem::path restoredFilePath = std::filesystem::path(targetPath) / entry.path().filename(); + 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(backupPath.c_str(), targetPath.c_str()); + CopyFileOverwrite(backupLocation.c_str(), targetLocation.c_str()); #endif } @@ -650,23 +648,23 @@ void pfile_write_hero(bool writeGameData) } bool pfile_write_hero_with_backup(bool writeGameData) -{ - const std::string backupPrefix = "backup_"; - const std::string backupPath = GetSavePath(gSaveNumber, backupPrefix); - const std::string savePath = GetSavePath(gSaveNumber); - - if (FileExists(savePath) || DirectoryExists(savePath.c_str())) - CopySaveFile(gSaveNumber, backupPath); - - pfile_write_hero(writeGameData); - - auto archive = OpenSaveArchive(gSaveNumber); +{ + const std::string backupPrefix = "backup_"; + const std::string backupLocation = GetSavePath(gSaveNumber, backupPrefix); + const std::string saveLocation = GetSavePath(gSaveNumber); + + if (FileExists(saveLocation) || DirectoryExists(saveLocation.c_str())) + CopySaveLocation(saveLocation, backupLocation); + + pfile_write_hero(writeGameData); + + auto archive = OpenSaveArchive(gSaveNumber); const bool saveIsValid = archive && ArchiveContainsGame(*archive); - if (saveIsValid || !(FileExists(backupPath) || DirectoryExists(backupPath.c_str()))) + if (saveIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) return saveIsValid; - RestoreSaveFile(savePath, backupPath); - + RestoreSaveLocation(saveLocation, backupLocation); + return false; } @@ -676,20 +674,11 @@ bool pfile_write_stash_with_backup() return true; const std::string backupPrefix = "backup_"; - const std::string backupPath = GetStashSavePath(backupPrefix); - const std::string stashPath = GetStashSavePath(); + const std::string backupLocation = GetStashSavePath(backupPrefix); + const std::string stashLocation = GetStashSavePath(); - if (FileExists(stashPath) || DirectoryExists(stashPath.c_str())) { -#if defined(UNPACKED_SAVES) - CreateDir(backupPath.c_str()); - for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(stashPath)) { - const std::filesystem::path targetFilePath = std::filesystem::path(backupPath) / entry.path().filename(); - CopyFileOverwrite(entry.path().string().c_str(), targetFilePath.string().c_str()); - } -#else - CopyFileOverwrite(stashPath.c_str(), backupPath.c_str()); -#endif - } + if (FileExists(stashLocation) || DirectoryExists(stashLocation.c_str())) + CopySaveLocation(stashLocation, backupLocation); SaveWriter stashWriter = GetStashWriter(); SaveStash(stashWriter); @@ -697,25 +686,25 @@ bool pfile_write_stash_with_backup() auto archive = OpenStashArchive(); const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; - if (stashIsValid || !(FileExists(backupPath) || DirectoryExists(backupPath.c_str()))) { + if (stashIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) { if (stashIsValid) Stash.dirty = false; return stashIsValid; } - RestoreSaveFile(stashPath, backupPath); + RestoreSaveLocation(stashLocation, backupLocation); return false; } #ifndef DISABLE_DEMOMODE -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()); - pfile_write_hero(saveWriter, true); -} +void pfile_write_hero_demo(int demo) +{ + 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); +} HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) { @@ -724,12 +713,12 @@ HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) if (!FileExists(referenceSavePath.c_str())) return { HeroCompareResult::ReferenceNotFound, {} }; - const std::string actualSavePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_actual_")); - { - CopySaveFile(gSaveNumber, actualSavePath); - SaveWriter saveWriter(actualSavePath.c_str()); - pfile_write_hero(saveWriter, true); - } + const std::string actualSavePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_actual_")); + { + CopySaveLocation(GetSavePath(gSaveNumber), actualSavePath); + SaveWriter saveWriter(actualSavePath.c_str()); + pfile_write_hero(saveWriter, true); + } return CompareSaves(actualSavePath, referenceSavePath, logDetails); } From d9a45533a1e432925a728718b22f6adf9266bad9 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:45:43 +0100 Subject: [PATCH 06/20] Fix autosave build for DVL_NO_FILESYSTEM targets Fall back to the regular save flow when UNPACKED_SAVES builds have no filesystem support, compile backup-copy restore paths only when filesystem support is available, and keep demo helpers as documented stubs in that configuration. --- Source/loadsave.cpp | 6 +++++ Source/pfile.cpp | 65 +++++++++++++++++++++++++++------------------ 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 30b10c72b..4773a8262 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -2929,6 +2929,11 @@ void SaveGameData(SaveWriter &saveWriter) void SaveGame() { gbValidSaveFile = true; + +#if defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM) + pfile_write_hero(/*writeGameData=*/true); + sfile_write_stash(); +#else const bool heroSaved = pfile_write_hero_with_backup(/*writeGameData=*/true); if (!heroSaved) { gbValidSaveFile = false; @@ -2939,6 +2944,7 @@ void SaveGame() gbValidSaveFile = false; return; } +#endif } void SaveLevel(SaveWriter &saveWriter) diff --git a/Source/pfile.cpp b/Source/pfile.cpp index ecf370c24..48fee9ebc 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -165,17 +165,15 @@ SaveWriter GetSaveWriter(uint32_t saveNum) return SaveWriter(GetSavePath(saveNum)); } -SaveWriter GetStashWriter() -{ - return SaveWriter(GetStashSavePath()); -} - +SaveWriter GetStashWriter() +{ + return SaveWriter(GetStashSavePath()); +} + +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) void CopySaveLocation(const std::string &sourceLocation, const std::string &targetLocation) { #if defined(UNPACKED_SAVES) -#ifdef DVL_NO_FILESYSTEM -#error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " -#endif if (!targetLocation.empty()) { CreateDir(targetLocation.c_str()); } @@ -191,9 +189,6 @@ void CopySaveLocation(const std::string &sourceLocation, const std::string &targ void RestoreSaveLocation(const std::string &targetLocation, const std::string &backupLocation) { #if defined(UNPACKED_SAVES) -#ifdef DVL_NO_FILESYSTEM -#error "UNPACKED_SAVES requires either DISABLE_DEMOMODE or C++17 " -#endif if (DirectoryExists(targetLocation.c_str())) { for (const std::filesystem::directory_entry &entry : std::filesystem::directory_iterator(targetLocation)) RemoveFile(entry.path().string().c_str()); @@ -207,6 +202,7 @@ void RestoreSaveLocation(const std::string &targetLocation, const std::string &b CopyFileOverwrite(backupLocation.c_str(), targetLocation.c_str()); #endif } +#endif void Game2UiPlayer(const Player &player, _uiheroinfo *heroinfo, bool bHasSaveFile) { @@ -641,12 +637,13 @@ const char *pfile_get_password() return gbIsMultiplayer ? PASSWORD_MULTI : PASSWORD_SINGLE; } -void pfile_write_hero(bool writeGameData) -{ - SaveWriter saveWriter = GetSaveWriter(gSaveNumber); - pfile_write_hero(saveWriter, writeGameData); -} - +void pfile_write_hero(bool writeGameData) +{ + SaveWriter saveWriter = GetSaveWriter(gSaveNumber); + pfile_write_hero(saveWriter, writeGameData); +} + +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) bool pfile_write_hero_with_backup(bool writeGameData) { const std::string backupPrefix = "backup_"; @@ -696,8 +693,10 @@ bool pfile_write_stash_with_backup() return false; } - -#ifndef DISABLE_DEMOMODE +#endif + +#ifndef DISABLE_DEMOMODE +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) void pfile_write_hero_demo(int demo) { const std::string saveLocation = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_reference_")); @@ -719,13 +718,27 @@ HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) SaveWriter saveWriter(actualSavePath.c_str()); pfile_write_hero(saveWriter, true); } - - return CompareSaves(actualSavePath, referenceSavePath, logDetails); -} -#endif - -void sfile_write_stash() -{ + + 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() +{ if (!Stash.dirty) return; From ca3b87d7034a0bd395b2851229d0f43ec1eeac45 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:59:24 +0100 Subject: [PATCH 07/20] Refine full save backup helper API Narrow the backup save helper to full game saves only. Replace pfile_write_hero_with_backup(bool writeGameData) with pfile_write_game_with_backup(), and always write full game data before validating the resulting archive with ArchiveContainsGame(). This makes the helper's name, behavior, and post-write validation consistent, and removes an API parameter that made the function look more general than its actual responsibility. Also update SaveGame() to use the new helper name and a clearer local variable. --- Source/loadsave.cpp | 4 ++-- Source/pfile.cpp | 4 ++-- Source/pfile.h | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 4773a8262..a2b302c94 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -2934,8 +2934,8 @@ void SaveGame() pfile_write_hero(/*writeGameData=*/true); sfile_write_stash(); #else - const bool heroSaved = pfile_write_hero_with_backup(/*writeGameData=*/true); - if (!heroSaved) { + const bool gameSaved = pfile_write_game_with_backup(); + if (!gameSaved) { gbValidSaveFile = false; return; } diff --git a/Source/pfile.cpp b/Source/pfile.cpp index 48fee9ebc..251c70205 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -644,7 +644,7 @@ void pfile_write_hero(bool writeGameData) } #if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) -bool pfile_write_hero_with_backup(bool writeGameData) +bool pfile_write_game_with_backup() { const std::string backupPrefix = "backup_"; const std::string backupLocation = GetSavePath(gSaveNumber, backupPrefix); @@ -653,7 +653,7 @@ bool pfile_write_hero_with_backup(bool writeGameData) if (FileExists(saveLocation) || DirectoryExists(saveLocation.c_str())) CopySaveLocation(saveLocation, backupLocation); - pfile_write_hero(writeGameData); + pfile_write_hero(/*writeGameData=*/true); auto archive = OpenSaveArchive(gSaveNumber); const bool saveIsValid = archive && ArchiveContainsGame(*archive); diff --git a/Source/pfile.h b/Source/pfile.h index 2e1b54f7c..9375c979e 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -100,8 +100,8 @@ std::optional OpenSaveArchive(uint32_t saveNum); 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_hero_with_backup(bool writeGameData = false); +void pfile_write_hero(bool writeGameData = false); +bool pfile_write_game_with_backup(); bool pfile_write_stash_with_backup(); #ifndef DISABLE_DEMOMODE From 422ff48e8687c0f8c726849c0dd05352fe4a5d48 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:18:03 +0100 Subject: [PATCH 08/20] Hide autosave interval Keep autosave interval controls available only in debug builds and remove autosave timing details from the release save menu. --- Source/gamemenu.cpp | 22 +++++++++++++--------- Source/options.cpp | 20 +++++++++++++------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/Source/gamemenu.cpp b/Source/gamemenu.cpp index b88e9f570..4a515fd53 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -104,11 +104,14 @@ void GamemenuUpdateSingle() sgSingleMenu[0].setEnabled(enable); } -std::string_view GetSaveGameMenuLabel() -{ - if (HasPendingAutoSave()) { - saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:s})")), _("ready")); - return saveGameMenuLabel; +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(); @@ -116,10 +119,11 @@ std::string_view GetSaveGameMenuLabel() saveGameMenuLabel = _("Save Game"); return saveGameMenuLabel; } - - saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); - return saveGameMenuLabel; -} + + saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); + return saveGameMenuLabel; +#endif +} void GamemenuPrevious(bool /*bActivate*/) { diff --git a/Source/options.cpp b/Source/options.cpp index ca249b2ba..c47fff11f 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -865,8 +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) - , autoSaveIntervalSeconds("Auto Save Interval", OptionEntryFlags::CantChangeInMultiPlayer, N_("Autosave interval (seconds)"), N_("Time between periodic autosave attempts."), 120, { 30, 60, 90, 120, 180, 300, 600 }) + , 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,11 +890,13 @@ std::vector GameplayOptions::GetEntries() &randomizeQuests, &theoQuest, &cowQuest, - &runInTown, - &quickCast, - &autoSaveEnabled, - &autoSaveIntervalSeconds, - &testBard, + &runInTown, + &quickCast, + &autoSaveEnabled, +#ifdef _DEBUG + &autoSaveIntervalSeconds, +#endif + &testBard, &testBarbarian, &experienceBar, &showItemGraphicsInStores, From b254733fba7bddedc92c6c7bb09435434a5244ba Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:34:18 +0100 Subject: [PATCH 09/20] fixed formatting using clang-format --- Source/gamemenu.cpp | 26 ++--- Source/loadsave.cpp | 40 +++---- Source/options.cpp | 26 ++--- Source/pfile.cpp | 276 ++++++++++++++++++++++---------------------- Source/pfile.h | 8 +- Source/player.cpp | 2 +- 6 files changed, 189 insertions(+), 189 deletions(-) diff --git a/Source/gamemenu.cpp b/Source/gamemenu.cpp index 4a515fd53..5fd82eecc 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -104,14 +104,14 @@ 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; +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(); @@ -119,11 +119,11 @@ std::string_view GetSaveGameMenuLabel() saveGameMenuLabel = _("Save Game"); return saveGameMenuLabel; } - - saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); - return saveGameMenuLabel; -#endif -} + + saveGameMenuLabel = fmt::format(fmt::runtime(_("Save Game ({:d})")), seconds); + return saveGameMenuLabel; +#endif +} void GamemenuPrevious(bool /*bActivate*/) { diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index a2b302c94..8b9b6d6d7 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -2926,26 +2926,26 @@ void SaveGameData(SaveWriter &saveWriter) SaveLevelSeeds(saveWriter); } -void SaveGame() -{ - gbValidSaveFile = true; - -#if defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM) - pfile_write_hero(/*writeGameData=*/true); - sfile_write_stash(); -#else - const bool gameSaved = pfile_write_game_with_backup(); - if (!gameSaved) { - gbValidSaveFile = false; - return; - } - - if (!pfile_write_stash_with_backup()) { - gbValidSaveFile = false; - return; - } -#endif -} +void SaveGame() +{ + gbValidSaveFile = true; + +#if defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM) + pfile_write_hero(/*writeGameData=*/true); + sfile_write_stash(); +#else + const bool gameSaved = pfile_write_game_with_backup(); + if (!gameSaved) { + gbValidSaveFile = false; + return; + } + + if (!pfile_write_stash_with_backup()) { + gbValidSaveFile = false; + return; + } +#endif +} void SaveLevel(SaveWriter &saveWriter) { diff --git a/Source/options.cpp b/Source/options.cpp index c47fff11f..5a8a91439 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -865,12 +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 + , 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 }) @@ -890,13 +890,13 @@ std::vector GameplayOptions::GetEntries() &randomizeQuests, &theoQuest, &cowQuest, - &runInTown, - &quickCast, - &autoSaveEnabled, -#ifdef _DEBUG - &autoSaveIntervalSeconds, -#endif - &testBard, + &runInTown, + &quickCast, + &autoSaveEnabled, +#ifdef _DEBUG + &autoSaveIntervalSeconds, +#endif + &testBard, &testBarbarian, &experienceBar, &showItemGraphicsInStores, diff --git a/Source/pfile.cpp b/Source/pfile.cpp index 251c70205..d2b0e9985 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -76,12 +76,12 @@ std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {}) ); } -std::string GetStashSavePath(std::string_view savePrefix = {}) -{ - return StrCat(paths::PrefPath(), savePrefix, - gbIsSpawn ? "stash_spawn" : "stash", -#ifdef UNPACKED_SAVES - gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR +std::string GetStashSavePath(std::string_view savePrefix = {}) +{ + return StrCat(paths::PrefPath(), savePrefix, + gbIsSpawn ? "stash_spawn" : "stash", +#ifdef UNPACKED_SAVES + gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR #else gbIsHellfire ? ".hsv" : ".sv" #endif @@ -165,44 +165,44 @@ SaveWriter GetSaveWriter(uint32_t saveNum) return SaveWriter(GetSavePath(saveNum)); } -SaveWriter GetStashWriter() -{ - return SaveWriter(GetStashSavePath()); -} - -#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) -void CopySaveLocation(const std::string &sourceLocation, const std::string &targetLocation) -{ -#if defined(UNPACKED_SAVES) - 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 -} - -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()); - } - 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(backupLocation.c_str(), targetLocation.c_str()); -#endif -} -#endif +SaveWriter GetStashWriter() +{ + return SaveWriter(GetStashSavePath()); +} + +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) +void CopySaveLocation(const std::string &sourceLocation, const std::string &targetLocation) +{ +#if defined(UNPACKED_SAVES) + 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 +} + +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()); + } + 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(backupLocation.c_str(), targetLocation.c_str()); +#endif +} +#endif void Game2UiPlayer(const Player &player, _uiheroinfo *heroinfo, bool bHasSaveFile) { @@ -637,73 +637,73 @@ const char *pfile_get_password() return gbIsMultiplayer ? PASSWORD_MULTI : PASSWORD_SINGLE; } -void pfile_write_hero(bool writeGameData) -{ - SaveWriter saveWriter = GetSaveWriter(gSaveNumber); - pfile_write_hero(saveWriter, writeGameData); -} - -#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) -bool pfile_write_game_with_backup() -{ - const std::string backupPrefix = "backup_"; - const std::string backupLocation = GetSavePath(gSaveNumber, backupPrefix); - const std::string saveLocation = GetSavePath(gSaveNumber); - - if (FileExists(saveLocation) || DirectoryExists(saveLocation.c_str())) - CopySaveLocation(saveLocation, backupLocation); - - pfile_write_hero(/*writeGameData=*/true); - - auto archive = OpenSaveArchive(gSaveNumber); - const bool saveIsValid = archive && ArchiveContainsGame(*archive); - if (saveIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) - return saveIsValid; - - RestoreSaveLocation(saveLocation, backupLocation); - - return false; -} - -bool pfile_write_stash_with_backup() -{ - if (!Stash.dirty) - return true; - - const std::string backupPrefix = "backup_"; - const std::string backupLocation = GetStashSavePath(backupPrefix); - const std::string stashLocation = GetStashSavePath(); - - if (FileExists(stashLocation) || DirectoryExists(stashLocation.c_str())) - CopySaveLocation(stashLocation, backupLocation); - - SaveWriter stashWriter = GetStashWriter(); - SaveStash(stashWriter); - - auto archive = OpenStashArchive(); - const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; - const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; - if (stashIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) { - if (stashIsValid) - Stash.dirty = false; - return stashIsValid; - } - - RestoreSaveLocation(stashLocation, backupLocation); - - return false; -} -#endif - -#ifndef DISABLE_DEMOMODE -#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) -void pfile_write_hero_demo(int demo) -{ - 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); -} +void pfile_write_hero(bool writeGameData) +{ + SaveWriter saveWriter = GetSaveWriter(gSaveNumber); + pfile_write_hero(saveWriter, writeGameData); +} + +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) +bool pfile_write_game_with_backup() +{ + const std::string backupPrefix = "backup_"; + const std::string backupLocation = GetSavePath(gSaveNumber, backupPrefix); + const std::string saveLocation = GetSavePath(gSaveNumber); + + if (FileExists(saveLocation) || DirectoryExists(saveLocation.c_str())) + CopySaveLocation(saveLocation, backupLocation); + + pfile_write_hero(/*writeGameData=*/true); + + auto archive = OpenSaveArchive(gSaveNumber); + const bool saveIsValid = archive && ArchiveContainsGame(*archive); + if (saveIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) + return saveIsValid; + + RestoreSaveLocation(saveLocation, backupLocation); + + return false; +} + +bool pfile_write_stash_with_backup() +{ + if (!Stash.dirty) + return true; + + const std::string backupPrefix = "backup_"; + const std::string backupLocation = GetStashSavePath(backupPrefix); + const std::string stashLocation = GetStashSavePath(); + + if (FileExists(stashLocation) || DirectoryExists(stashLocation.c_str())) + CopySaveLocation(stashLocation, backupLocation); + + SaveWriter stashWriter = GetStashWriter(); + SaveStash(stashWriter); + + auto archive = OpenStashArchive(); + const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; + const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; + if (stashIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) { + if (stashIsValid) + Stash.dirty = false; + return stashIsValid; + } + + RestoreSaveLocation(stashLocation, backupLocation); + + return false; +} +#endif + +#ifndef DISABLE_DEMOMODE +#if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) +void pfile_write_hero_demo(int demo) +{ + 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); +} HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) { @@ -712,33 +712,33 @@ HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) if (!FileExists(referenceSavePath.c_str())) return { HeroCompareResult::ReferenceNotFound, {} }; - const std::string actualSavePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_actual_")); - { - 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() -{ + const std::string actualSavePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_actual_")); + { + 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() +{ if (!Stash.dirty) return; diff --git a/Source/pfile.h b/Source/pfile.h index 9375c979e..6960cd857 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -100,9 +100,9 @@ std::optional OpenSaveArchive(uint32_t saveNum); 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_game_with_backup(); -bool pfile_write_stash_with_backup(); +void pfile_write_hero(bool writeGameData = false); +bool pfile_write_game_with_backup(); +bool pfile_write_stash_with_backup(); #ifndef DISABLE_DEMOMODE /** @@ -119,7 +119,7 @@ void pfile_write_hero_demo(int demo); HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails); #endif -void sfile_write_stash(); +void sfile_write_stash(); bool pfile_ui_set_hero_infos(bool (*uiAddHeroInfo)(_uiheroinfo *)); void pfile_ui_set_class_stats(HeroClass playerClass, _uidefaultstats *classStats); uint32_t pfile_ui_get_first_unused_save_num(); diff --git a/Source/player.cpp b/Source/player.cpp index 019c9a147..ba21a1fc9 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -1531,7 +1531,7 @@ uint16_t GetPlayerSpriteWidth(HeroClass cls, player_graphic graphic, PlayerWeapo return spriteData.bow; return spriteData.attack; case player_graphic::Hit: - return spriteData.swHit; + return spriteData.swHit; case player_graphic::Block: return spriteData.block; case player_graphic::Lightning: From bf4a369360f140957b5ef0218fe4ea48ccb90879 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:50:16 +0100 Subject: [PATCH 10/20] Add SDL_TICKS_PASSED fallback Add SDL_TICKS_PASSED fallback definitions in the shared SDL compatibility headers so SDL1-family targets do not fail when the macro is missing from the platform SDL headers. --- Source/utils/sdl2_to_1_2_backports.h | 1 + Source/utils/sdl_compat.h | 4 ++++ 2 files changed, 5 insertions(+) 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 diff --git a/Source/utils/sdl_compat.h b/Source/utils/sdl_compat.h index 898a151e5..56690864c 100644 --- a/Source/utils/sdl_compat.h +++ b/Source/utils/sdl_compat.h @@ -19,6 +19,10 @@ #endif #endif +#ifndef SDL_TICKS_PASSED +#define SDL_TICKS_PASSED(A, B) ((Sint32)((B) - (A)) <= 0) +#endif + #ifdef USE_SDL1 #define SDL_Scancode Uint8 #endif From 0840f6742ba1a16e1788de8faed7b146a4740bf2 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:28:36 +0100 Subject: [PATCH 11/20] Remove redundant IsAutoSavePending wrapper and fix SDL_TICKS_PASSED location --- Source/diablo.cpp | 14 ++++---------- Source/diablo.h | 1 - Source/utils/sdl2_backports.h | 4 ++++ Source/utils/sdl_compat.h | 3 --- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 5111b0954..3f0c594cb 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -831,11 +831,9 @@ void GameEventHandler(const SDL_Event &event, uint16_t modState) ReleaseKey(SDLC_EventKey(event)); return; case SDL_EVENT_MOUSE_MOTION: - if (ControlMode == ControlTypes::KeyboardAndMouse) { - if (invflag) - InvalidateInventorySlot(); - MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; - } + if (ControlMode == ControlTypes::KeyboardAndMouse && invflag) + InvalidateInventorySlot(); + MousePosition = { SDLC_EventMotionIntX(event), SDLC_EventMotionIntY(event) }; gmenu_on_mouse_move(); return; case SDL_EVENT_MOUSE_BUTTON_DOWN: @@ -1948,7 +1946,7 @@ int GetSecondsUntilNextAutoSave() if (!*GetOptions().Gameplay.autoSaveEnabled) return -1; - if (IsAutoSavePending()) + if (HasPendingAutoSave()) return 0; const uint32_t now = SDL_GetTicks(); @@ -1975,10 +1973,6 @@ void RequestAutoSave(AutoSaveReason reason) QueueAutoSave(reason); } -bool IsAutoSavePending() -{ - return HasPendingAutoSave(); -} void QueueAutoSave(AutoSaveReason reason) { diff --git a/Source/diablo.h b/Source/diablo.h index 5b47bc9c9..86c378cc9 100644 --- a/Source/diablo.h +++ b/Source/diablo.h @@ -113,7 +113,6 @@ void MarkCombatActivity(); bool IsAutoSaveSafe(); int GetSecondsUntilNextAutoSave(); bool HasPendingAutoSave(); -bool IsAutoSavePending(); void RequestAutoSave(AutoSaveReason reason); void QueueAutoSave(AutoSaveReason reason); bool AttemptAutoSave(AutoSaveReason reason); 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/sdl_compat.h b/Source/utils/sdl_compat.h index 56690864c..e60e3d33a 100644 --- a/Source/utils/sdl_compat.h +++ b/Source/utils/sdl_compat.h @@ -19,9 +19,6 @@ #endif #endif -#ifndef SDL_TICKS_PASSED -#define SDL_TICKS_PASSED(A, B) ((Sint32)((B) - (A)) <= 0) -#endif #ifdef USE_SDL1 #define SDL_Scancode Uint8 From e5b9559f92af51e40e744bf1884eae441a5628b1 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:35:18 +0100 Subject: [PATCH 12/20] Track autosave enabled state and reset timer on toggle --- Source/diablo.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 3f0c594cb..7ec96fdce 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -182,6 +182,7 @@ AutoSaveReason pendingAutoSaveReason = AutoSaveReason::None; bool hasEnteredActiveGameplay = false; uint32_t autoSaveCooldownUntil = 0; uint32_t autoSaveCombatCooldownUntil = 0; +bool wasAutoSaveEnabled = false; constexpr uint32_t AutoSaveCooldownMilliseconds = 5000; constexpr uint32_t AutoSaveCombatCooldownMilliseconds = 4000; constexpr int AutoSaveEnemyProximityTiles = 6; @@ -250,6 +251,7 @@ void StartGame(interface_mode uMsg) autoSaveCombatCooldownUntil = 0; pendingAutoSaveReason = AutoSaveReason::None; autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + wasAutoSaveEnabled = *GetOptions().Gameplay.autoSaveEnabled; } void FreeGame() @@ -1612,14 +1614,20 @@ void GameLogic() if (!hasEnteredActiveGameplay && LastPlayerAction != PlayerActionType::None) hasEnteredActiveGameplay = true; - if (*GetOptions().Gameplay.autoSaveEnabled) { + const bool autoSaveEnabled = *GetOptions().Gameplay.autoSaveEnabled; + if (autoSaveEnabled != wasAutoSaveEnabled) { + if (!autoSaveEnabled) + pendingAutoSaveReason = AutoSaveReason::None; + + autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); + wasAutoSaveEnabled = autoSaveEnabled; + } + + if (autoSaveEnabled) { const uint32_t now = SDL_GetTicks(); if (SDL_TICKS_PASSED(now, autoSaveNextTimerDueAt)) { QueueAutoSave(AutoSaveReason::Timer); } - } else { - autoSaveNextTimerDueAt = SDL_GetTicks() + GetAutoSaveIntervalMilliseconds(); - pendingAutoSaveReason = AutoSaveReason::None; } if (HasPendingAutoSave() && IsAutoSaveSafe()) { From 1a00a97ef3a038bf8f8e50b013f8f30843fd4986 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:53:10 +0100 Subject: [PATCH 13/20] Clarify save semantics for manual saves and autosaves Split the save flow into explicit manual and auto save kinds and return a SaveResult instead of overloading gbValidSaveFile as the outcome of the last save attempt. Redesign backup handling so the persistent backup slot represents the last manual save, while autosave uses its own temporary restore copy and no longer overwrites the manual backup. Manual save UI now reports failure separately from preserved-save recovery, and autosave only reports success when the new save actually succeeds. --- Source/diablo.cpp | 8 +++--- Source/gamemenu.cpp | 19 ++++++++++-- Source/loadsave.cpp | 53 ++++++++++++++++++++++++---------- Source/loadsave.h | 13 ++++++++- Source/pfile.cpp | 70 ++++++++++++++++++++++++++++++++++++++------- Source/pfile.h | 3 +- 6 files changed, 131 insertions(+), 35 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 7ec96fdce..0e2c2f7cc 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -1981,7 +1981,6 @@ void RequestAutoSave(AutoSaveReason reason) QueueAutoSave(reason); } - void QueueAutoSave(AutoSaveReason reason) { if (gbIsMultiplayer) @@ -2003,11 +2002,12 @@ bool AttemptAutoSave(AutoSaveReason reason) const EventHandler saveProc = SetEventHandler(DisableInputEventHandler); const uint32_t currentTime = SDL_GetTicks(); - SaveGame(); + const SaveResult saveResult = SaveGame(SaveKind::Auto); const uint32_t afterSaveTime = SDL_GetTicks(); + const bool saveSucceeded = saveResult == SaveResult::Success; autoSaveCooldownUntil = afterSaveTime + AutoSaveCooldownMilliseconds; - if (gbValidSaveFile) { + if (saveSucceeded) { autoSaveNextTimerDueAt = afterSaveTime + GetAutoSaveIntervalMilliseconds(); if (reason != AutoSaveReason::Timer) { const int timeElapsed = static_cast(afterSaveTime - currentTime); @@ -2016,7 +2016,7 @@ bool AttemptAutoSave(AutoSaveReason reason) } } SetEventHandler(saveProc); - return gbValidSaveFile; + return saveSucceeded; } void InitKeymapActions() diff --git a/Source/gamemenu.cpp b/Source/gamemenu.cpp index 5fd82eecc..e331fec2b 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -40,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); @@ -377,12 +380,22 @@ 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: + InitDiabloMsg(EMSG_GAME_SAVED, currentTime + 1000 - SDL_GetTicks()); + 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(); } diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 8b9b6d6d7..98ad84ae6 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -54,8 +54,30 @@ namespace { constexpr size_t MaxMissilesForSaveGame = 125; constexpr size_t PlayerWalkPathSizeForSaveGame = 25; -uint8_t giNumberQuests; -uint8_t giNumberOfSmithPremiumItems; +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())); +} + +SaveResult GetSaveFailureResult() +{ + gbValidSaveFile = ActiveSaveContainsGame(); + return gbValidSaveFile ? SaveResult::FailedButPreviousSavePreserved : SaveResult::FailedNoValidSave; +} template T SwapLE(T in) @@ -2680,7 +2702,7 @@ tl::expected LoadGame(bool firstflag) gbProcessPlayers = IsDiabloAlive(!firstflag); if (gbIsHellfireSaveGame != gbIsHellfire) { - SaveGame(); + SaveGame(SaveKind::Manual); } gbIsHellfireSaveGame = gbIsHellfire; @@ -2926,24 +2948,25 @@ 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 - const bool gameSaved = pfile_write_game_with_backup(); - if (!gameSaved) { - gbValidSaveFile = false; - return; - } + const bool gameSaved = kind == SaveKind::Manual + ? pfile_write_manual_game_with_backup() + : pfile_write_auto_game(); + if (!gameSaved) + return GetSaveFailureResult(); - if (!pfile_write_stash_with_backup()) { - gbValidSaveFile = false; - return; - } + gbValidSaveFile = true; + if (!pfile_write_stash_with_backup()) + return GetSaveFailureResult(); + + return SaveResult::Success; #endif } diff --git a/Source/loadsave.h b/Source/loadsave.h index 0d139e143..0531edc5b 100644 --- a/Source/loadsave.h +++ b/Source/loadsave.h @@ -18,6 +18,17 @@ namespace devilution { extern DVL_API_FOR_TEST bool gbIsHellfireSaveGame; extern DVL_API_FOR_TEST uint8_t giNumberOfLevels; +enum class SaveKind : uint8_t { + Manual, + Auto, +}; + +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 +51,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/pfile.cpp b/Source/pfile.cpp index d2b0e9985..a4865c252 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -171,6 +171,11 @@ SaveWriter GetStashWriter() } #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) { #if defined(UNPACKED_SAVES) @@ -202,6 +207,22 @@ void RestoreSaveLocation(const std::string &targetLocation, const std::string &b 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 void Game2UiPlayer(const Player &player, _uiheroinfo *heroinfo, bool bHasSaveFile) @@ -644,25 +665,52 @@ void pfile_write_hero(bool writeGameData) } #if !(defined(UNPACKED_SAVES) && defined(DVL_NO_FILESYSTEM)) -bool pfile_write_game_with_backup() +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 (FileExists(saveLocation) || DirectoryExists(saveLocation.c_str())) + if (SaveLocationExists(saveLocation)) CopySaveLocation(saveLocation, backupLocation); - pfile_write_hero(/*writeGameData=*/true); + return WriteGameAndRestoreOnFailure(backupLocation); +} - auto archive = OpenSaveArchive(gSaveNumber); - const bool saveIsValid = archive && ArchiveContainsGame(*archive); - if (saveIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) - return saveIsValid; +bool pfile_write_auto_game() +{ + const std::string restorePrefix = "autosave_restore_"; + const std::string restoreLocation = GetSavePath(gSaveNumber, restorePrefix); + const std::string saveLocation = GetSavePath(gSaveNumber); - RestoreSaveLocation(saveLocation, backupLocation); + if (SaveLocationExists(saveLocation)) + CopySaveLocation(saveLocation, restoreLocation); - return false; + const bool saveIsValid = WriteGameAndRestoreOnFailure(restoreLocation); + DeleteSaveLocation(restoreLocation); + return saveIsValid; } bool pfile_write_stash_with_backup() @@ -674,7 +722,7 @@ bool pfile_write_stash_with_backup() const std::string backupLocation = GetStashSavePath(backupPrefix); const std::string stashLocation = GetStashSavePath(); - if (FileExists(stashLocation) || DirectoryExists(stashLocation.c_str())) + if (SaveLocationExists(stashLocation)) CopySaveLocation(stashLocation, backupLocation); SaveWriter stashWriter = GetStashWriter(); @@ -683,7 +731,7 @@ bool pfile_write_stash_with_backup() auto archive = OpenStashArchive(); const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; - if (stashIsValid || !(FileExists(backupLocation) || DirectoryExists(backupLocation.c_str()))) { + if (stashIsValid || !SaveLocationExists(backupLocation)) { if (stashIsValid) Stash.dirty = false; return stashIsValid; diff --git a/Source/pfile.h b/Source/pfile.h index 6960cd857..307d20d5c 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -101,7 +101,8 @@ 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_game_with_backup(); +bool pfile_write_manual_game_with_backup(); +bool pfile_write_auto_game(); bool pfile_write_stash_with_backup(); #ifndef DISABLE_DEMOMODE From 577444d292d5fd2d517da493c1acfcd0c572417e Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:59:21 +0100 Subject: [PATCH 14/20] Use active monsters instead of proximity for autosave safety and run clang --- Source/diablo.cpp | 25 +++++--------------- Source/loadsave.cpp | 48 +++++++++++++++++++-------------------- Source/utils/sdl_compat.h | 1 - 3 files changed, 30 insertions(+), 44 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index 0e2c2f7cc..ebe28e9c9 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -185,7 +185,6 @@ uint32_t autoSaveCombatCooldownUntil = 0; bool wasAutoSaveEnabled = false; constexpr uint32_t AutoSaveCooldownMilliseconds = 5000; constexpr uint32_t AutoSaveCombatCooldownMilliseconds = 4000; -constexpr int AutoSaveEnemyProximityTiles = 6; uint32_t GetAutoSaveIntervalMilliseconds() { @@ -1886,7 +1885,7 @@ const auto OptionChangeHandlerLanguage = (GetOptions().Language.code.SetValueCha } // namespace -bool IsEnemyTooCloseForAutoSave(); +bool HasActiveMonstersForAutoSave(); bool IsAutoSaveSafe() { @@ -1911,7 +1910,7 @@ bool IsAutoSaveSafe() if (qtextflag || DropGoldFlag || IsWithdrawGoldOpen || pcurs != CURSOR_HAND) return false; - if (leveltype != DTYPE_TOWN && IsEnemyTooCloseForAutoSave()) + if (leveltype != DTYPE_TOWN && HasActiveMonstersForAutoSave()) return false; return true; @@ -1922,28 +1921,16 @@ void MarkCombatActivity() autoSaveCombatCooldownUntil = SDL_GetTicks() + AutoSaveCombatCooldownMilliseconds; } -bool IsEnemyTooCloseForAutoSave() +bool HasActiveMonstersForAutoSave() { - if (MyPlayer == nullptr) - return false; - - const Point playerPosition = MyPlayer->position.tile; - for (size_t i = 0; i < ActiveMonsterCount; i++) { - const Monster &monster = Monsters[ActiveMonsters[i]]; + for (const Monster &monster : Monsters) { if (monster.hitPoints <= 0 || monster.mode == MonsterMode::Death || monster.mode == MonsterMode::Petrified) continue; - if (monster.type().type == MT_GOLEM) + if (monster.activeForTicks == 0) continue; - if ((monster.flags & MFLAG_HIDDEN) != 0) - continue; - - const int distance = std::max( - std::abs(monster.position.tile.x - playerPosition.x), - std::abs(monster.position.tile.y - playerPosition.y)); - if (distance <= AutoSaveEnemyProximityTiles) - return true; + return true; } return false; diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 98ad84ae6..9fafb01a2 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -54,30 +54,30 @@ namespace { constexpr size_t MaxMissilesForSaveGame = 125; 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())); -} - -SaveResult GetSaveFailureResult() -{ - gbValidSaveFile = ActiveSaveContainsGame(); - return gbValidSaveFile ? SaveResult::FailedButPreviousSavePreserved : SaveResult::FailedNoValidSave; -} +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())); +} + +SaveResult GetSaveFailureResult() +{ + gbValidSaveFile = ActiveSaveContainsGame(); + return gbValidSaveFile ? SaveResult::FailedButPreviousSavePreserved : SaveResult::FailedNoValidSave; +} template T SwapLE(T in) diff --git a/Source/utils/sdl_compat.h b/Source/utils/sdl_compat.h index e60e3d33a..898a151e5 100644 --- a/Source/utils/sdl_compat.h +++ b/Source/utils/sdl_compat.h @@ -19,7 +19,6 @@ #endif #endif - #ifdef USE_SDL1 #define SDL_Scancode Uint8 #endif From 13fe71693845cb11f8f8cca634edf59154758117 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:18:15 +0100 Subject: [PATCH 15/20] Split stash save paths for manual and auto saves --- Source/loadsave.cpp | 21 +++++++++++++------- Source/pfile.cpp | 48 ++++++++++++++++++++++++++++++++++----------- Source/pfile.h | 3 ++- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 9fafb01a2..8cd7c972d 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -2956,15 +2956,22 @@ SaveResult SaveGame(SaveKind kind) gbValidSaveFile = true; return SaveResult::Success; #else - const bool gameSaved = kind == SaveKind::Manual - ? pfile_write_manual_game_with_backup() - : pfile_write_auto_game(); - if (!gameSaved) - return GetSaveFailureResult(); + if (kind == SaveKind::Manual) { + if (!pfile_write_manual_game_with_backup()) + return GetSaveFailureResult(); + } else { + if (!pfile_write_auto_game()) + return GetSaveFailureResult(); + } gbValidSaveFile = true; - if (!pfile_write_stash_with_backup()) - return GetSaveFailureResult(); + if (kind == SaveKind::Manual) { + if (!pfile_write_manual_stash_with_backup()) + return GetSaveFailureResult(); + } else { + if (!pfile_write_auto_stash()) + return GetSaveFailureResult(); + } return SaveResult::Success; #endif diff --git a/Source/pfile.cpp b/Source/pfile.cpp index a4865c252..a77dc0e07 100644 --- a/Source/pfile.cpp +++ b/Source/pfile.cpp @@ -713,34 +713,60 @@ bool pfile_write_auto_game() return saveIsValid; } -bool pfile_write_stash_with_backup() +bool WriteStashAndRestoreOnFailure(std::string_view restorePrefix, bool deleteRestoreLocationOnSuccess) { if (!Stash.dirty) return true; - const std::string backupPrefix = "backup_"; - const std::string backupLocation = GetStashSavePath(backupPrefix); + const std::string restoreLocation = GetStashSavePath(restorePrefix); const std::string stashLocation = GetStashSavePath(); - if (SaveLocationExists(stashLocation)) - CopySaveLocation(stashLocation, backupLocation); - SaveWriter stashWriter = GetStashWriter(); SaveStash(stashWriter); auto archive = OpenStashArchive(); const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; const bool stashIsValid = archive && ReadArchive(*archive, stashFileName) != nullptr; - if (stashIsValid || !SaveLocationExists(backupLocation)) { - if (stashIsValid) - Stash.dirty = false; - return stashIsValid; + if (stashIsValid) { + Stash.dirty = false; + if (deleteRestoreLocationOnSuccess) + DeleteSaveLocation(restoreLocation); + return true; } - RestoreSaveLocation(stashLocation, backupLocation); + 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 diff --git a/Source/pfile.h b/Source/pfile.h index 307d20d5c..d41c00d44 100644 --- a/Source/pfile.h +++ b/Source/pfile.h @@ -103,7 +103,8 @@ std::unique_ptr ReadArchive(SaveReader &archive, const char *pszNam void pfile_write_hero(bool writeGameData = false); bool pfile_write_manual_game_with_backup(); bool pfile_write_auto_game(); -bool pfile_write_stash_with_backup(); +bool pfile_write_manual_stash_with_backup(); +bool pfile_write_auto_stash(); #ifndef DISABLE_DEMOMODE /** From 8fea8b3cd248c01e2383d122602357388aa06416 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:20:17 +0100 Subject: [PATCH 16/20] Clamp manual save message display time --- Source/gamemenu.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/gamemenu.cpp b/Source/gamemenu.cpp index e331fec2b..47ba4b5e2 100644 --- a/Source/gamemenu.cpp +++ b/Source/gamemenu.cpp @@ -383,9 +383,13 @@ void gamemenu_save_game(bool /*bActivate*/) const SaveResult saveResult = SaveGame(SaveKind::Manual); ClrDiabloMsg(); switch (saveResult) { - case SaveResult::Success: - InitDiabloMsg(EMSG_GAME_SAVED, currentTime + 1000 - SDL_GetTicks()); + 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; From 515f8c042785c158c7df340782bc751ff92011f5 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:21:45 +0100 Subject: [PATCH 17/20] Add system save kind for migration writes --- Source/loadsave.cpp | 2 +- Source/loadsave.h | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 8cd7c972d..b78219de3 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -2702,7 +2702,7 @@ tl::expected LoadGame(bool firstflag) gbProcessPlayers = IsDiabloAlive(!firstflag); if (gbIsHellfireSaveGame != gbIsHellfire) { - SaveGame(SaveKind::Manual); + SaveGame(SaveKind::System); } gbIsHellfireSaveGame = gbIsHellfire; diff --git a/Source/loadsave.h b/Source/loadsave.h index 0531edc5b..071dffb17 100644 --- a/Source/loadsave.h +++ b/Source/loadsave.h @@ -21,6 +21,7 @@ extern DVL_API_FOR_TEST uint8_t giNumberOfLevels; enum class SaveKind : uint8_t { Manual, Auto, + System, }; enum class SaveResult : uint8_t { From ccc1d1759b74535fb65440987e2817f738bf3694 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:31:14 +0100 Subject: [PATCH 18/20] Reformat loadsave with repo clang-format Run the repository-pinned .\\.tools\\clang-format-18\\clang-format.exe on loadsave.cpp and verify the change with the required Debug devilutionx build. This records the formatting step with the project-mandated formatter after the save failure fix. --- Source/loadsave.cpp | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index b78219de3..a4ea5be81 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -73,10 +73,23 @@ bool ActiveSaveContainsGame() return IsHeaderValid(LoadLE32(gameData.get())); } +bool ActiveSaveContainsStash() +{ + auto archive = OpenStashArchive(); + if (!archive) + return false; + + const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; + return ReadArchive(*archive, stashFileName) != nullptr; +} + SaveResult GetSaveFailureResult() { - gbValidSaveFile = ActiveSaveContainsGame(); - return gbValidSaveFile ? SaveResult::FailedButPreviousSavePreserved : SaveResult::FailedNoValidSave; + const bool hasValidGame = ActiveSaveContainsGame(); + const bool hasValidStash = ActiveSaveContainsStash(); + + gbValidSaveFile = hasValidGame; + return (hasValidGame && hasValidStash) ? SaveResult::FailedButPreviousSavePreserved : SaveResult::FailedNoValidSave; } template @@ -2956,21 +2969,29 @@ SaveResult SaveGame(SaveKind kind) gbValidSaveFile = true; return SaveResult::Success; #else - if (kind == SaveKind::Manual) { + switch (kind) { + case SaveKind::Manual: if (!pfile_write_manual_game_with_backup()) return GetSaveFailureResult(); - } else { + break; + case SaveKind::Auto: + case SaveKind::System: if (!pfile_write_auto_game()) return GetSaveFailureResult(); + break; } gbValidSaveFile = true; - if (kind == SaveKind::Manual) { + switch (kind) { + case SaveKind::Manual: if (!pfile_write_manual_stash_with_backup()) return GetSaveFailureResult(); - } else { + break; + case SaveKind::Auto: + case SaveKind::System: if (!pfile_write_auto_stash()) return GetSaveFailureResult(); + break; } return SaveResult::Success; From 3a651763e3411db9370e09fd6f37f4b73d4496a9 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:39:57 +0100 Subject: [PATCH 19/20] Relax stash failure classification Treat a missing stash archive as an acceptable state when classifying save failures. This preserves the previous game save result instead of reporting no valid save when the player never created a stash. --- Source/loadsave.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index a4ea5be81..5043df630 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -77,7 +77,7 @@ bool ActiveSaveContainsStash() { auto archive = OpenStashArchive(); if (!archive) - return false; + return true; const char *stashFileName = gbIsMultiplayer ? "mpstashitems" : "spstashitems"; return ReadArchive(*archive, stashFileName) != nullptr; From c1a6b61cdc7518cb4787cf5053429b5be5d91ff6 Mon Sep 17 00:00:00 2001 From: morfidon <57798071+morfidon@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:16:40 +0100 Subject: [PATCH 20/20] Fix autosave SDL3 tick comparisons Autosave used SDL_TICKS_PASSED in diablo.cpp, but that macro is not available in SDL3, which broke the Linux SDL3 CI build. Switch the autosave timer state to the SDL_GetTicks return type and compare deadlines through a small local helper. The helper keeps SDL_TICKS_PASSED semantics on SDL1/SDL2 while using a direct comparison on SDL3, so this fixes the SDL3 compile error without broad timer refactoring in other code paths. --- Source/diablo.cpp | 48 +++++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/Source/diablo.cpp b/Source/diablo.cpp index ebe28e9c9..eaa19b552 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -176,19 +176,35 @@ bool was_archives_init = false; /** To know if surfaces have been initialized or not */ bool was_window_init = false; bool was_ui_init = false; -uint32_t autoSaveNextTimerDueAt = 0; +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; -uint32_t autoSaveCooldownUntil = 0; -uint32_t autoSaveCombatCooldownUntil = 0; +TickCount autoSaveCooldownUntil = 0; +TickCount autoSaveCombatCooldownUntil = 0; bool wasAutoSaveEnabled = false; -constexpr uint32_t AutoSaveCooldownMilliseconds = 5000; -constexpr uint32_t AutoSaveCombatCooldownMilliseconds = 4000; +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); +} -uint32_t GetAutoSaveIntervalMilliseconds() +TickCount GetAutoSaveIntervalMilliseconds() { - return static_cast(std::max(1, *GetOptions().Gameplay.autoSaveIntervalSeconds)) * 1000; + return static_cast(std::max(1, *GetOptions().Gameplay.autoSaveIntervalSeconds)) * 1000; } int GetAutoSavePriority(AutoSaveReason reason) @@ -1623,8 +1639,8 @@ void GameLogic() } if (autoSaveEnabled) { - const uint32_t now = SDL_GetTicks(); - if (SDL_TICKS_PASSED(now, autoSaveNextTimerDueAt)) { + const TickCount now = SDL_GetTicks(); + if (HaveTicksPassed(now, autoSaveNextTimerDueAt)) { QueueAutoSave(AutoSaveReason::Timer); } } @@ -1895,10 +1911,10 @@ bool IsAutoSaveSafe() if (!hasEnteredActiveGameplay) return false; - if (!SDL_TICKS_PASSED(SDL_GetTicks(), autoSaveCooldownUntil)) + if (!HaveTicksPassed(autoSaveCooldownUntil)) return false; - if (!SDL_TICKS_PASSED(SDL_GetTicks(), autoSaveCombatCooldownUntil)) + if (!HaveTicksPassed(autoSaveCombatCooldownUntil)) return false; if (movie_playing || PauseMode != 0 || gmenu_is_active() || IsPlayerInStore()) @@ -1944,11 +1960,11 @@ int GetSecondsUntilNextAutoSave() if (HasPendingAutoSave()) return 0; - const uint32_t now = SDL_GetTicks(); - if (SDL_TICKS_PASSED(now, autoSaveNextTimerDueAt)) + const TickCount now = SDL_GetTicks(); + if (HaveTicksPassed(now, autoSaveNextTimerDueAt)) return 0; - const uint32_t remainingMilliseconds = autoSaveNextTimerDueAt - now; + const TickCount remainingMilliseconds = autoSaveNextTimerDueAt - now; return static_cast((remainingMilliseconds + 999) / 1000); } @@ -1988,9 +2004,9 @@ bool AttemptAutoSave(AutoSaveReason reason) return false; const EventHandler saveProc = SetEventHandler(DisableInputEventHandler); - const uint32_t currentTime = SDL_GetTicks(); + const TickCount currentTime = SDL_GetTicks(); const SaveResult saveResult = SaveGame(SaveKind::Auto); - const uint32_t afterSaveTime = SDL_GetTicks(); + const TickCount afterSaveTime = SDL_GetTicks(); const bool saveSucceeded = saveResult == SaveResult::Success; autoSaveCooldownUntil = afterSaveTime + AutoSaveCooldownMilliseconds;