/** * @file pfile.cpp * * Implementation of the save game encoding functionality. */ #include "pfile.h" #include #include #include #include #include #include #ifdef USE_SDL3 #include #include #else #include #endif #include "codec.h" #include "engine/load_file.hpp" #include "engine/render/primitive_render.hpp" #include "game_mode.hpp" #include "loadsave.h" #include "menu.h" #include "mpq/mpq_common.hpp" #include "pack.h" #include "qol/stash.h" #include "tables/playerdat.hpp" #include "utils/endian_read.hpp" #include "utils/endian_swap.hpp" #include "utils/file_util.h" #include "utils/language.h" #include "utils/parse_int.hpp" #include "utils/paths.h" #include "utils/sdl_compat.h" #include "utils/stdcompat/filesystem.hpp" #include "utils/str_cat.hpp" #include "utils/str_split.hpp" #include "utils/utf8.hpp" #ifdef UNPACKED_SAVES #include "utils/file_util.h" #ifdef __DREAMCAST__ #include "platform/dreamcast/dc_init.hpp" #include "platform/dreamcast/dc_save_codec.hpp" #endif #else #include "mpq/mpq_reader.hpp" #endif namespace devilution { #define PASSWORD_SPAWN_SINGLE "adslhfb1" #define PASSWORD_SPAWN_MULTI "lshbkfg1" #define PASSWORD_SINGLE "xrgyrkj1" #define PASSWORD_MULTI "szqnlsk1" bool gbValidSaveFile; namespace { /** List of character names for the character selection screen. */ char hero_names[MAX_CHARACTERS][PlayerNameLength]; #ifdef __DREAMCAST__ constexpr uint32_t DreamcastMaxSaveSlots = 8; #endif uint32_t GetPlatformSaveSlotCount() { #ifdef __DREAMCAST__ return std::min(MAX_CHARACTERS, DreamcastMaxSaveSlots); #else return MAX_CHARACTERS; #endif } #ifdef __DREAMCAST__ bool IsRamSavePath(std::string_view path) { return path.size() >= 5 && path.substr(0, 5) == "/ram/"; } uint64_t HashSaveEntryKey(std::string_view key) { // FNV-1a 64-bit hash for deterministic, compact VMU file keys. uint64_t hash = 14695981039346656037ULL; for (const char ch : key) { hash ^= static_cast(ch); hash *= 1099511628211ULL; } return hash; } std::string MakeMappedVmuFilename(std::string_view saveDir, std::string_view filename) { constexpr char HexDigits[] = "0123456789abcdef"; const std::string key = StrCat(saveDir, "|", filename); uint64_t hash = HashSaveEntryKey(key); std::string mapped(12, '0'); for (int i = 11; i >= 0; --i) { mapped[i] = HexDigits[hash & 0xF]; hash >>= 4; } return mapped; } std::string MakeLegacyVmuFilename(std::string_view saveDir, std::string_view filename) { if (!IsRamSavePath(saveDir)) return {}; const std::string legacy = StrCat(saveDir.substr(5), filename); if (legacy.size() > 12) return {}; return legacy; } bool WriteBytesToPath(const std::string &path, const std::byte *data, size_t size) { FILE *file = OpenFile(path.c_str(), "wb"); if (file == nullptr) return false; const bool ok = std::fwrite(data, size, 1, file) == 1; std::fclose(file); return ok; } bool RestoreRamFileFromVmu(const std::string &saveDir, const char *filename) { if (!IsRamSavePath(saveDir) || !dc::IsVmuAvailable()) return false; const std::string localPath = saveDir + filename; const auto tryRestore = [&](const std::string &vmuFilename) { if (vmuFilename.empty() || !dc::VmuFileExists(dc::GetVmuPath(), vmuFilename.c_str())) return false; size_t size = 0; auto data = dc::ReadFromVmu(dc::GetVmuPath(), vmuFilename.c_str(), size); if (!data || size == 0) { LogError("[DC Save] VMU read failed for {}", vmuFilename); return false; } if (!WriteBytesToPath(localPath, data.get(), size)) { LogError("[DC Save] Cannot restore {} to {}", vmuFilename, localPath); return false; } LogVerbose("[DC Save] Restored {} bytes from VMU {} to {}", size, vmuFilename, localPath); return true; }; if (tryRestore(MakeMappedVmuFilename(saveDir, filename))) return true; // Backward compatibility for old short VMU names (e.g. dvx_s0_hero). return tryRestore(MakeLegacyVmuFilename(saveDir, filename)); } #endif std::string GetSavePath(uint32_t saveNum, std::string_view savePrefix = {}) { #ifdef __DREAMCAST__ // Dreamcast keeps active saves in /ram/ with flat file prefixes. // VMU persistence uses mapped 12-char keys (see MakeMappedVmuFilename). return StrCat(paths::PrefPath(), "dvx_", savePrefix, gbIsSpawn ? (gbIsMultiplayer ? "sh" : "sp") // share/spawn shortened for VMU : (gbIsMultiplayer ? "m" : "s"), // multi/single shortened saveNum, gbIsHellfire ? "h_" : "_" // hellfire indicator + separator ); #else return StrCat(paths::PrefPath(), savePrefix, gbIsSpawn ? (gbIsMultiplayer ? "share_" : "spawn_") : (gbIsMultiplayer ? "multi_" : "single_"), saveNum, #ifdef UNPACKED_SAVES gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR #else gbIsHellfire ? ".hsv" : ".sv" #endif ); #endif } std::string GetStashSavePath() { #ifdef __DREAMCAST__ // Flat file for stash on Dreamcast return StrCat(paths::PrefPath(), "dvx_", gbIsSpawn ? "stash_sp" : "stash", gbIsHellfire ? "h_" : "_"); #else return StrCat(paths::PrefPath(), gbIsSpawn ? "stash_spawn" : "stash", #ifdef UNPACKED_SAVES gbIsHellfire ? "_hsv" DIRECTORY_SEPARATOR_STR : "_sv" DIRECTORY_SEPARATOR_STR #else gbIsHellfire ? ".hsv" : ".sv" #endif ); #endif } bool GetSaveNames(uint8_t index, std::string_view prefix, char *out) { char suf; if (index < giNumberOfLevels) suf = 'l'; else if (index < giNumberOfLevels * 2) { index -= giNumberOfLevels; suf = 's'; } else { return false; } *BufCopy(out, prefix, std::string_view(&suf, 1), LeftPad(index, 2, '0')) = '\0'; return true; } bool GetPermSaveNames(uint8_t dwIndex, char *szPerm) { return GetSaveNames(dwIndex, "perm", szPerm); } bool GetTempSaveNames(uint8_t dwIndex, char *szTemp) { return GetSaveNames(dwIndex, "temp", szTemp); } void RenameTempToPerm(SaveWriter &saveWriter) { char szTemp[MaxMpqPathSize]; char szPerm[MaxMpqPathSize]; uint32_t dwIndex = 0; while (GetTempSaveNames(dwIndex, szTemp)) { [[maybe_unused]] const bool result = GetPermSaveNames(dwIndex, szPerm); // DO NOT PUT DIRECTLY INTO ASSERT! assert(result); dwIndex++; if (saveWriter.HasFile(szTemp)) { if (saveWriter.HasFile(szPerm)) saveWriter.RemoveHashEntry(szPerm); saveWriter.RenameFile(szTemp, szPerm); } } assert(!GetPermSaveNames(dwIndex, szPerm)); } bool ReadHero(SaveReader &archive, PlayerPack *pPack) { size_t read; auto buf = ReadArchive(archive, "hero", &read); if (buf == nullptr) return false; bool ret = false; if (read == sizeof(*pPack)) { memcpy(pPack, buf.get(), sizeof(*pPack)); ret = true; } return ret; } void EncodeHero(SaveWriter &saveWriter, const PlayerPack *pack) { const size_t packedLen = codec_get_encoded_len(sizeof(*pack)); const std::unique_ptr packed { new std::byte[packedLen] }; memcpy(packed.get(), pack, sizeof(*pack)); codec_encode(packed.get(), sizeof(*pack), packedLen, pfile_get_password()); saveWriter.WriteFile("hero", packed.get(), packedLen); } SaveWriter GetSaveWriter(uint32_t saveNum) { return SaveWriter(GetSavePath(saveNum)); } 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 Game2UiPlayer(const Player &player, _uiheroinfo *heroinfo, bool bHasSaveFile) { CopyUtf8(heroinfo->name, player._pName, sizeof(heroinfo->name)); heroinfo->level = player.getCharacterLevel(); heroinfo->heroclass = player._pClass; heroinfo->strength = player._pStrength; heroinfo->magic = player._pMagic; heroinfo->dexterity = player._pDexterity; heroinfo->vitality = player._pVitality; heroinfo->hassaved = bHasSaveFile; heroinfo->herorank = player.pDiabloKillLevel; heroinfo->spawned = gbIsSpawn; } bool GetFileName(uint8_t lvl, char *dst) { if (gbIsMultiplayer) { if (lvl != 0) return false; memcpy(dst, "hero", 5); return true; } if (GetPermSaveNames(lvl, dst)) { return true; } if (lvl == giNumberOfLevels * 2) { memcpy(dst, "game", 5); return true; } if (lvl == giNumberOfLevels * 2 + 1) { memcpy(dst, "hero", 5); return true; } return false; } bool ArchiveContainsGame(SaveReader &hsArchive) { if (gbIsMultiplayer) return false; auto gameData = ReadArchive(hsArchive, "game"); if (gameData == nullptr) return false; const uint32_t hdr = LoadLE32(gameData.get()); return IsHeaderValid(hdr); } std::optional CreateSaveReader(std::string &&path) { #ifdef UNPACKED_SAVES #ifdef __DREAMCAST__ if (path.empty()) return std::nullopt; SaveReader reader(std::move(path)); if (!reader.HasFile("hero")) return std::nullopt; return reader; #else // For other platforms, path is a directory if (!FileExists(path)) return std::nullopt; #endif return SaveReader(std::move(path)); #else std::int32_t error; return MpqArchive::Open(path.c_str(), error); #endif } #ifndef DISABLE_DEMOMODE struct CompareInfo { std::unique_ptr &data; size_t currentPosition; size_t size; bool isTownLevel; bool dataExists; }; struct CompareCounter { int reference; int actual; int max() const { return std::max(reference, actual); } void checkIfDataExists(int count, CompareInfo &compareInfoReference, CompareInfo &compareInfoActual) const { if (reference == count) compareInfoReference.dataExists = false; if (actual == count) compareInfoActual.dataExists = false; } }; inline bool string_ends_with(std::string_view value, std::string_view suffix) { if (suffix.size() > value.size()) return false; return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin()); } void CreateDetailDiffs(std::string_view prefix, std::string_view memoryMapFile, CompareInfo &compareInfoReference, CompareInfo &compareInfoActual, ankerl::unordered_dense::segmented_map &foundDiffs) { // Note: Detail diffs are currently only supported in unit tests const std::string memoryMapFileAssetName = StrCat(paths::BasePath(), "/test/fixtures/memory_map/", memoryMapFile, ".txt"); SDL_IOStream *handle = SDL_IOFromFile(memoryMapFileAssetName.c_str(), "r"); if (handle == nullptr) { app_fatal(StrCat("MemoryMapFile ", memoryMapFile, " is missing")); return; } const size_t readBytes = static_cast(SDL_GetIOSize(handle)); const std::unique_ptr memoryMapFileData { new std::byte[readBytes] }; SDL_ReadIO(handle, memoryMapFileData.get(), readBytes); SDL_CloseIO(handle); const std::string_view buffer(reinterpret_cast(memoryMapFileData.get()), readBytes); ankerl::unordered_dense::segmented_map counter; auto getCounter = [&](const std::string &counterAsString) { auto it = counter.find(counterAsString); if (it != counter.end()) return it->second; const ParseIntResult countFromMapFile = ParseInt(counterAsString); if (!countFromMapFile.has_value()) app_fatal(StrCat("Failed to parse ", counterAsString, " as int")); return CompareCounter { countFromMapFile.value(), countFromMapFile.value() }; }; auto addDiff = [&](const std::string &diffKey) { auto it = foundDiffs.find(diffKey); if (it == foundDiffs.end()) { foundDiffs.insert_or_assign(diffKey, 1); } else { foundDiffs.insert_or_assign(diffKey, it->second + 1); } }; auto compareBytes = [&](size_t countBytes) { if (compareInfoReference.dataExists && compareInfoReference.currentPosition + countBytes > compareInfoReference.size) app_fatal(StrCat("Comparison failed. Not enough bytes in reference to compare. Location: ", prefix)); if (compareInfoActual.dataExists && compareInfoActual.currentPosition + countBytes > compareInfoActual.size) app_fatal(StrCat("Comparison failed. Not enough bytes in actual to compare. Location: ", prefix)); bool result = true; if (compareInfoReference.dataExists && compareInfoActual.dataExists) result = memcmp(compareInfoReference.data.get() + compareInfoReference.currentPosition, compareInfoActual.data.get() + compareInfoActual.currentPosition, countBytes) == 0; if (compareInfoReference.dataExists) compareInfoReference.currentPosition += countBytes; if (compareInfoActual.dataExists) compareInfoActual.currentPosition += countBytes; return result; }; auto read32BitInt = [&](CompareInfo &compareInfo, bool useLE) { int32_t value = 0; if (!compareInfo.dataExists) return value; if (compareInfo.currentPosition + sizeof(value) > compareInfo.size) app_fatal("read32BitInt failed. Too less bytes to read."); memcpy(&value, compareInfo.data.get() + compareInfo.currentPosition, sizeof(value)); if (useLE) value = Swap32LE(value); else value = Swap32BE(value); return value; }; for (std::string_view line : SplitByChar(buffer, '\n')) { if (!line.empty() && line.back() == '\r') line.remove_suffix(1); if (line.empty()) continue; const auto tokens = SplitByChar(line, ' '); auto it = tokens.begin(); const auto end = tokens.end(); if (it == end) continue; std::string_view command = *it; const bool dataExistsReference = compareInfoReference.dataExists; const bool dataExistsActual = compareInfoActual.dataExists; if (string_ends_with(command, "_HF")) { if (!gbIsHellfire) continue; command.remove_suffix(3); } if (string_ends_with(command, "_DA")) { if (gbIsHellfire) continue; command.remove_suffix(3); } if (string_ends_with(command, "_DL")) { if (compareInfoReference.isTownLevel && compareInfoActual.isTownLevel) continue; if (compareInfoReference.isTownLevel) compareInfoReference.dataExists = false; if (compareInfoActual.isTownLevel) compareInfoActual.dataExists = false; command.remove_suffix(3); } if (command == "R" || command == "LT" || command == "LC" || command == "LC_LE") { const auto bitsAsString = std::string(*++it); const auto comment = std::string(*++it); const ParseIntResult parsedBytes = ParseInt(bitsAsString); if (!parsedBytes.has_value()) app_fatal(StrCat("Failed to parse ", bitsAsString, " as size_t")); const size_t bytes = static_cast(parsedBytes.value() / 8); if (command == "LT") { const int32_t valueReference = read32BitInt(compareInfoReference, false); const int32_t valueActual = read32BitInt(compareInfoActual, false); assert(sizeof(valueReference) == bytes); compareInfoReference.isTownLevel = valueReference == 0; compareInfoActual.isTownLevel = valueActual == 0; } if (command == "LC" || command == "LC_LE") { const int32_t valueReference = read32BitInt(compareInfoReference, command == "LC_LE"); const int32_t valueActual = read32BitInt(compareInfoActual, command == "LC_LE"); assert(sizeof(valueReference) == bytes); counter.insert_or_assign(std::string(comment), CompareCounter { valueReference, valueActual }); } if (!compareBytes(bytes)) { const std::string diffKey = StrCat(prefix, ".", comment); addDiff(diffKey); } } else if (command == "M") { const auto countAsString = std::string(*++it); const auto bitsAsString = std::string(*++it); const std::string_view comment = *++it; const CompareCounter count = getCounter(countAsString); const ParseIntResult parsedBytes = ParseInt(bitsAsString); if (!parsedBytes.has_value()) app_fatal(StrCat("Failed to parse ", bitsAsString, " as size_t")); const size_t bytes = static_cast(parsedBytes.value() / 8); for (int i = 0; i < count.max(); i++) { count.checkIfDataExists(i, compareInfoReference, compareInfoActual); if (!compareBytes(bytes)) { const std::string diffKey = StrCat(prefix, ".", comment); addDiff(diffKey); } } } else if (command == "C") { const auto countAsString = std::string(*++it); auto subMemoryMapFile = std::string(*++it); const auto comment = std::string(*++it); const CompareCounter count = getCounter(countAsString); subMemoryMapFile.erase(std::remove(subMemoryMapFile.begin(), subMemoryMapFile.end(), '\r'), subMemoryMapFile.end()); for (int i = 0; i < count.max(); i++) { count.checkIfDataExists(i, compareInfoReference, compareInfoActual); const std::string subPrefix = StrCat(prefix, ".", comment); CreateDetailDiffs(subPrefix, subMemoryMapFile, compareInfoReference, compareInfoActual, foundDiffs); } } compareInfoReference.dataExists = dataExistsReference; compareInfoActual.dataExists = dataExistsActual; } } struct CompareTargets { std::string fileName; std::string memoryMapFileName; bool isTownLevel; }; HeroCompareResult CompareSaves(const std::string &actualSavePath, const std::string &referenceSavePath, bool logDetails) { std::vector possibleFileToCheck; possibleFileToCheck.push_back({ "hero", "hero", false }); possibleFileToCheck.push_back({ "game", "game", false }); possibleFileToCheck.push_back({ "additionalMissiles", "additionalMissiles", false }); char szPerm[MaxMpqPathSize]; for (int i = 0; GetPermSaveNames(i, szPerm); i++) { possibleFileToCheck.push_back({ std::string(szPerm), "level", i == 0 }); } SaveReader actualArchive = *CreateSaveReader(std::string(actualSavePath)); SaveReader referenceArchive = *CreateSaveReader(std::string(referenceSavePath)); bool compareResult = true; std::string message; for (const auto &compareTarget : possibleFileToCheck) { size_t fileSizeActual = 0; auto fileDataActual = ReadArchive(actualArchive, compareTarget.fileName.c_str(), &fileSizeActual); size_t fileSizeReference = 0; auto fileDataReference = ReadArchive(referenceArchive, compareTarget.fileName.c_str(), &fileSizeReference); if (fileDataActual.get() == nullptr && fileDataReference.get() == nullptr) { continue; } if (fileSizeActual == fileSizeReference && memcmp(fileDataReference.get(), fileDataActual.get(), fileSizeActual) == 0) continue; compareResult = false; if (!message.empty()) message.append("\n"); if (fileSizeActual != fileSizeReference) StrAppend(message, "file \"", compareTarget.fileName, "\" is different size. Expected: ", fileSizeReference, " Actual: ", fileSizeActual); else StrAppend(message, "file \"", compareTarget.fileName, "\" has different content."); if (!logDetails) continue; ankerl::unordered_dense::segmented_map foundDiffs; CompareInfo compareInfoReference = { fileDataReference, 0, fileSizeReference, compareTarget.isTownLevel, fileSizeReference != 0 }; CompareInfo compareInfoActual = { fileDataActual, 0, fileSizeActual, compareTarget.isTownLevel, fileSizeActual != 0 }; CreateDetailDiffs(compareTarget.fileName, compareTarget.memoryMapFileName, compareInfoReference, compareInfoActual, foundDiffs); if (compareInfoReference.currentPosition != fileSizeReference) app_fatal(StrCat("Comparison failed. Uncompared bytes in reference. File: ", compareTarget.fileName)); if (compareInfoActual.currentPosition != fileSizeActual) app_fatal(StrCat("Comparison failed. Uncompared bytes in actual. File: ", compareTarget.fileName)); for (const auto &[location, count] : foundDiffs) { StrAppend(message, "\nDiff found in ", location, " count: ", count); } } return { compareResult ? HeroCompareResult::Same : HeroCompareResult::Difference, message }; } #endif // !DISABLE_DEMOMODE void pfile_write_hero(SaveWriter &saveWriter, bool writeGameData) { if (writeGameData) { SaveGameData(saveWriter); RenameTempToPerm(saveWriter); } PlayerPack pkplr; Player &myPlayer = *MyPlayer; PackPlayer(pkplr, myPlayer); EncodeHero(saveWriter, &pkplr); if (!gbVanilla) { SaveHotkeys(saveWriter, myPlayer); SaveHeroItems(saveWriter, myPlayer); } } void RemoveAllInvalidItems(Player &player) { for (int i = 0; i < NUM_INVLOC; i++) RemoveInvalidItem(player.InvBody[i]); for (int i = 0; i < player._pNumInv; i++) RemoveInvalidItem(player.InvList[i]); for (int i = 0; i < MaxBeltItems; i++) RemoveInvalidItem(player.SpdList[i]); RemoveEmptyInventory(player); } } // namespace #ifdef UNPACKED_SAVES bool SaveReader::HasFile(const char *path) { const std::string filePath = dir_ + path; #ifdef __DREAMCAST__ if (IsRamSavePath(dir_) && !FileExists(filePath.c_str())) return RestoreRamFileFromVmu(dir_, path); #endif return FileExists(filePath.c_str()); } std::unique_ptr SaveReader::ReadFile(const char *filename, std::size_t &fileSize, int32_t &error) { std::unique_ptr result; error = 0; const std::string path = dir_ + filename; #ifdef __DREAMCAST__ if (IsRamSavePath(dir_)) { FILE *file = OpenFile(path.c_str(), "rb"); if (file == nullptr) { if (!RestoreRamFileFromVmu(dir_, filename)) { LogError("[DC Save] Cannot open {} for reading", path); error = 1; return nullptr; } file = OpenFile(path.c_str(), "rb"); if (file == nullptr) { LogError("[DC Save] Cannot open {} after VMU restore", path); error = 1; return nullptr; } } std::fseek(file, 0, SEEK_END); long fileLen = std::ftell(file); std::fseek(file, 0, SEEK_SET); if (fileLen <= 0) { LogError("[DC Save] Empty or invalid file {}: ftell={}", path, fileLen); std::fclose(file); error = 1; return nullptr; } size_t size = static_cast(fileLen); fileSize = size; result.reset(new (std::nothrow) std::byte[size]); if (!result) { LogError("[DC Save] Allocation failed for {} bytes from {}", size, path); std::fclose(file); error = 1; return nullptr; } size_t bytesRead = std::fread(result.get(), 1, size, file); std::fclose(file); if (bytesRead != size) { LogError("[DC Save] Short read {}: expected {} got {}", path, size, bytesRead); error = 1; return nullptr; } return result; } // VMU path - use zlib save containers size_t decompressedSize = 0; result = dc::ReadCompressedFile(path.c_str(), decompressedSize); if (!result) { error = 1; return nullptr; } fileSize = decompressedSize; return result; #else uintmax_t size; if (!GetFileSize(path.c_str(), &size)) { error = 1; return nullptr; } fileSize = size; FILE *file = OpenFile(path.c_str(), "rb"); if (file == nullptr) { error = 1; return nullptr; } result.reset(new std::byte[size]); if (std::fread(result.get(), size, 1, file) != 1) { std::fclose(file); error = 1; return nullptr; } std::fclose(file); return result; #endif } bool SaveWriter::WriteFile(const char *filename, const std::byte *data, size_t size) { #ifdef __DREAMCAST__ if (dir_.empty()) { return false; } #endif const std::string path = dir_ + filename; #ifdef __DREAMCAST__ if (IsRamSavePath(dir_)) { FILE *file = OpenFile(path.c_str(), "wb"); if (file == nullptr) { LogError("[DC Save] WriteFile: cannot open {} for writing", path); return false; } if (std::fwrite(data, size, 1, file) != 1) { LogError("[DC Save] WriteFile: fwrite failed for {} ({} bytes)", path, size); std::fclose(file); return false; } std::fclose(file); LogVerbose("[DC Save] WriteFile: wrote {} bytes to {}", size, path); // Mirror RAM saves to VMU so saves survive emulator/console restarts. if (dc::IsVmuAvailable()) { const std::string vmuFilename = MakeMappedVmuFilename(dir_, filename); if (!dc::WriteToVmu(dc::GetVmuPath(), vmuFilename.c_str(), data, size)) { LogError("[DC Save] VMU persist failed for {} ({})", path, vmuFilename); return false; } } return true; } // VMU path - use zlib save containers return dc::WriteCompressedFile(path.c_str(), data, size); #else FILE *file = OpenFile(path.c_str(), "wb"); if (file == nullptr) { return false; } if (std::fwrite(data, size, 1, file) != 1) { std::fclose(file); return false; } std::fclose(file); return true; #endif } void SaveWriter::RemoveHashEntries(bool (*fnGetName)(uint8_t, char *)) { char pszFileName[MaxMpqPathSize]; for (uint8_t i = 0; fnGetName(i, pszFileName); i++) { RemoveHashEntry(pszFileName); } } #endif std::optional OpenSaveArchive(uint32_t saveNum) { return CreateSaveReader(GetSavePath(saveNum)); } std::optional OpenStashArchive() { return CreateSaveReader(GetStashSavePath()); } std::unique_ptr ReadArchive(SaveReader &archive, const char *pszName, size_t *pdwLen) { int32_t error; std::size_t length; std::unique_ptr result = archive.ReadFile(pszName, length, error); if (error != 0) return nullptr; const std::size_t decodedLength = codec_decode(result.get(), length, pfile_get_password()); if (decodedLength == 0) return nullptr; if (pdwLen != nullptr) *pdwLen = decodedLength; return result; } const char *pfile_get_password() { if (gbIsSpawn) return gbIsMultiplayer ? PASSWORD_SPAWN_MULTI : PASSWORD_SPAWN_SINGLE; return gbIsMultiplayer ? PASSWORD_MULTI : PASSWORD_SINGLE; } void pfile_write_hero(bool writeGameData) { SaveWriter saveWriter = GetSaveWriter(gSaveNumber); pfile_write_hero(saveWriter, writeGameData); } #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); } HeroCompareResult pfile_compare_hero_demo(int demo, bool logDetails) { const std::string referenceSavePath = GetSavePath(gSaveNumber, StrCat("demo_", demo, "_reference_")); 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); } return CompareSaves(actualSavePath, referenceSavePath, logDetails); } #endif void sfile_write_stash() { if (!Stash.dirty) return; SaveWriter stashWriter = GetStashWriter(); SaveStash(stashWriter); Stash.dirty = false; } bool pfile_ui_set_hero_infos(bool (*uiAddHeroInfo)(_uiheroinfo *)) { memset(hero_names, 0, sizeof(hero_names)); for (uint32_t i = 0; i < GetPlatformSaveSlotCount(); i++) { std::optional archive = OpenSaveArchive(i); if (archive) { PlayerPack pkplr; if (ReadHero(*archive, &pkplr)) { _uiheroinfo uihero; uihero.saveNumber = i; strcpy(hero_names[i], pkplr.pName); const bool hasSaveGame = ArchiveContainsGame(*archive); if (hasSaveGame) pkplr.bIsHellfire = gbIsHellfireSaveGame ? 1 : 0; Player &player = Players[0]; UnPackPlayer(pkplr, player); #ifndef __DREAMCAST__ LoadHeroItems(player); RemoveAllInvalidItems(player); CalcPlrInv(player, false); #endif Game2UiPlayer(player, &uihero, hasSaveGame); uiAddHeroInfo(&uihero); } } } return true; } void pfile_ui_set_class_stats(HeroClass playerClass, _uidefaultstats *classStats) { const ClassAttributes &classAttributes = GetClassAttributes(playerClass); classStats->strength = classAttributes.baseStr; classStats->magic = classAttributes.baseMag; classStats->dexterity = classAttributes.baseDex; classStats->vitality = classAttributes.baseVit; } uint32_t pfile_ui_get_first_unused_save_num() { uint32_t saveNum; const uint32_t saveSlotCount = GetPlatformSaveSlotCount(); for (saveNum = 0; saveNum < saveSlotCount; saveNum++) { if (hero_names[saveNum][0] == '\0') break; } return saveNum; } bool pfile_ui_save_create(_uiheroinfo *heroinfo) { PlayerPack pkplr; const uint32_t saveNum = heroinfo->saveNumber; if (saveNum >= GetPlatformSaveSlotCount()) return false; heroinfo->saveNumber = saveNum; giNumberOfLevels = gbIsHellfire ? 25 : 17; SaveWriter saveWriter = GetSaveWriter(saveNum); saveWriter.RemoveHashEntries(GetFileName); CopyUtf8(hero_names[saveNum], heroinfo->name, sizeof(hero_names[saveNum])); Player &player = Players[0]; CreatePlayer(player, heroinfo->heroclass); CopyUtf8(player._pName, heroinfo->name, PlayerNameLength); PackPlayer(pkplr, player); EncodeHero(saveWriter, &pkplr); Game2UiPlayer(player, heroinfo, false); if (!gbVanilla) { SaveHotkeys(saveWriter, player); SaveHeroItems(saveWriter, player); } return true; } bool pfile_delete_save(_uiheroinfo *heroInfo) { const uint32_t saveNum = heroInfo->saveNumber; if (saveNum < MAX_CHARACTERS) { hero_names[saveNum][0] = '\0'; RemoveFile(GetSavePath(saveNum).c_str()); } return true; } void pfile_read_player_from_save(uint32_t saveNum, Player &player) { PlayerPack pkplr; { std::optional archive = OpenSaveArchive(saveNum); if (!archive) app_fatal(_("Unable to open archive")); if (!ReadHero(*archive, &pkplr)) app_fatal(_("Unable to load character")); gbValidSaveFile = ArchiveContainsGame(*archive); if (gbValidSaveFile) pkplr.bIsHellfire = gbIsHellfireSaveGame ? 1 : 0; } UnPackPlayer(pkplr, player); LoadHeroItems(player); RemoveAllInvalidItems(player); CalcPlrInv(player, false); } void pfile_save_level() { SaveWriter saveWriter = GetSaveWriter(gSaveNumber); SaveLevel(saveWriter); } tl::expected pfile_convert_levels() { SaveWriter saveWriter = GetSaveWriter(gSaveNumber); return ConvertLevels(saveWriter); } void pfile_remove_temp_files() { if (gbIsMultiplayer) return; SaveWriter saveWriter = GetSaveWriter(gSaveNumber); saveWriter.RemoveHashEntries(GetTempSaveNames); } void pfile_update(bool forceSave) { static Uint32 prevTick; if (!gbIsMultiplayer) return; const Uint32 tick = SDL_GetTicks(); if (!forceSave && tick - prevTick <= 60000) return; prevTick = tick; pfile_write_hero(); sfile_write_stash(); } } // namespace devilution