diff --git a/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp b/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp index 5e1b84ec22a..c1fe622c6fe 100644 --- a/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp +++ b/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp @@ -24,8 +24,10 @@ #include #include +#include #include +#include using json = nlohmann::ordered_json; using namespace Rando; @@ -40,6 +42,10 @@ namespace { std::string placementtxt; } // namespace +static std::string SafeJsonString(std::string value) { + return SohUtils::SanitizeUtf8(std::move(value)); +} + void GenerateHash() { auto ctx = Rando::Context::GetInstance(); std::string hash = ctx->GetHash(); @@ -73,13 +79,16 @@ static void WriteLocation(std::string sphere, const RandomizerCheck locationKey, switch (gSaveContext.language) { case LANGUAGE_ENG: default: - jsonData["playthrough"][sphere][location->GetName()] = itemLocation->GetPlacedItemName().GetEnglish(); + jsonData["playthrough"][sphere][location->GetName()] = + SafeJsonString(itemLocation->GetPlacedItemName().GetEnglish()); break; case LANGUAGE_GER: - jsonData["playthrough"][sphere][location->GetName()] = itemLocation->GetPlacedItemName().GetGerman(); + jsonData["playthrough"][sphere][location->GetName()] = + SafeJsonString(itemLocation->GetPlacedItemName().GetGerman()); break; case LANGUAGE_FRA: - jsonData["playthrough"][sphere][location->GetName()] = itemLocation->GetPlacedItemName().GetFrench(); + jsonData["playthrough"][sphere][location->GetName()] = + SafeJsonString(itemLocation->GetPlacedItemName().GetFrench()); break; } } @@ -130,7 +139,7 @@ static void WriteShuffledEntrance(std::string sphereString, Entrance* entrance) case LANGUAGE_GER: case LANGUAGE_FRA: default: - jsonData["entrancesMap"][sphereString][name] = text; + jsonData["entrancesMap"][sphereString][name] = SafeJsonString(text); break; } } @@ -141,7 +150,8 @@ static void WriteSettings() { std::array options = Rando::Settings::GetInstance()->GetAllOptions(); for (const Rando::Option& option : options) { if (option.GetName() != "") { - jsonData["settings"][option.GetName()] = option.GetOptionText(ctx->GetOption(option.GetKey()).Get()); + jsonData["settings"][option.GetName()] = + SafeJsonString(option.GetOptionText(ctx->GetOption(option.GetKey()).Get())); } } } @@ -162,7 +172,7 @@ static void WriteExcludedLocations() { continue; } - jsonData["excludedLocations"].push_back(RemoveLineBreaks(location->GetName())); + jsonData["excludedLocations"].push_back(SafeJsonString(RemoveLineBreaks(location->GetName()))); } } } @@ -174,7 +184,8 @@ static void WriteStartingInventory() { for (const Rando::OptionGroup* subGroup : optionGroup.GetSubGroups()) { if (subGroup->GetContainsType() == Rando::OptionGroupType::DEFAULT) { for (Rando::Option* option : subGroup->GetOptions()) { - jsonData["settings"][option->GetName()] = option->GetOptionText(ctx->GetOption(option->GetKey()).Get()); + jsonData["settings"][option->GetName()] = + SafeJsonString(option->GetOptionText(ctx->GetOption(option->GetKey()).Get())); } } } @@ -188,7 +199,7 @@ static void WriteEnabledTricks() { if (ctx->GetTrickOption(static_cast(setting->GetKey())).IsNot(RO_GENERIC_ON)) { continue; } - jsonData["enabledTricks"].push_back(RemoveLineBreaks(setting->GetName()).c_str()); + jsonData["enabledTricks"].push_back(SafeJsonString(RemoveLineBreaks(setting->GetName()))); } } @@ -200,7 +211,7 @@ static void WriteMasterQuestDungeons() { if (dungeon->IsVanilla()) { continue; } - jsonData["masterQuestDungeons"].push_back(dungeon->GetName()); + jsonData["masterQuestDungeons"].push_back(SafeJsonString(dungeon->GetName())); } } @@ -210,7 +221,7 @@ static void WriteChosenOptions() { for (const auto& trial : ctx->GetTrials()->GetTrialList()) { if (trial->IsRequired()) { std::string trialName = trial->GetName().GetForCurrentLanguage(MF_CLEAN); - jsonData["requiredTrials"].push_back(RemoveLineBreaks(trialName)); + jsonData["requiredTrials"].push_back(SafeJsonString(RemoveLineBreaks(trialName))); } } if (ctx->GetOption(RSK_SELECTED_STARTING_AGE).Is(RO_AGE_ADULT)) { @@ -276,13 +287,13 @@ static void WriteAllLocations() { if (!location->HasCustomPrice() && location->GetPlacedRandomizerGet() != RG_ICE_TRAP) { jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] = - placedItemName; + SafeJsonString(placedItemName); continue; } // We're dealing with a complex item, build out the json object for it jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()]["item"] = - placedItemName; + SafeJsonString(placedItemName); if (location->HasCustomPrice()) { jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()]["price"] = @@ -297,30 +308,33 @@ static void WriteAllLocations() { case 0: default: jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] - ["model"] = Rando::StaticData::RetrieveItem( - ctx->overrides[location->GetRandomizerCheck()].LooksLike()) - .GetName() - .GetEnglish(); + ["model"] = SafeJsonString(Rando::StaticData::RetrieveItem( + ctx->overrides[location->GetRandomizerCheck()].LooksLike()) + .GetName() + .GetEnglish()); jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] - ["trickName"] = ctx->overrides[location->GetRandomizerCheck()].GetTrickName().GetEnglish(); + ["trickName"] = SafeJsonString( + ctx->overrides[location->GetRandomizerCheck()].GetTrickName().GetEnglish()); break; case 1: jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] - ["model"] = Rando::StaticData::RetrieveItem( - ctx->overrides[location->GetRandomizerCheck()].LooksLike()) - .GetName() - .GetGerman(); + ["model"] = SafeJsonString(Rando::StaticData::RetrieveItem( + ctx->overrides[location->GetRandomizerCheck()].LooksLike()) + .GetName() + .GetGerman()); jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] - ["trickName"] = ctx->overrides[location->GetRandomizerCheck()].GetTrickName().GetGerman(); + ["trickName"] = SafeJsonString( + ctx->overrides[location->GetRandomizerCheck()].GetTrickName().GetGerman()); break; case 2: jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] - ["model"] = Rando::StaticData::RetrieveItem( - ctx->overrides[location->GetRandomizerCheck()].LooksLike()) - .GetName() - .GetFrench(); + ["model"] = SafeJsonString(Rando::StaticData::RetrieveItem( + ctx->overrides[location->GetRandomizerCheck()].LooksLike()) + .GetName() + .GetFrench()); jsonData["locations"][Rando::StaticData::GetLocation(location->GetRandomizerCheck())->GetName()] - ["trickName"] = ctx->overrides[location->GetRandomizerCheck()].GetTrickName().GetFrench(); + ["trickName"] = SafeJsonString( + ctx->overrides[location->GetRandomizerCheck()].GetTrickName().GetFrench()); break; } } @@ -330,59 +344,65 @@ static void WriteAllLocations() { void SpoilerLog_Write() { auto ctx = Rando::Context::GetInstance(); - jsonData.clear(); - - jsonData["version"] = (char*)gBuildVersion; - jsonData["fileType"] = FILE_TYPE_SPOILER; - jsonData["git_branch"] = (char*)gGitBranch; - jsonData["git_commit"] = (char*)gGitCommitHash; - jsonData["seed"] = ctx->GetSeedString(); - jsonData["finalSeed"] = ctx->GetSeed(); - - // Write Hash - int index = 0; - for (uint8_t seed_value : ctx->hashIconIndexes) { - jsonData["file_hash"][index] = seed_value; - index++; - } - - WriteSettings(); - WriteExcludedLocations(); - WriteStartingInventory(); - WriteEnabledTricks(); - WriteMasterQuestDungeons(); - WriteChosenOptions(); - WritePlaythrough(); + try { + jsonData.clear(); + hintedLocations.clear(); + + jsonData["version"] = (char*)gBuildVersion; + jsonData["fileType"] = FILE_TYPE_SPOILER; + jsonData["git_branch"] = (char*)gGitBranch; + jsonData["git_commit"] = (char*)gGitCommitHash; + jsonData["seed"] = SafeJsonString(ctx->GetSeedString()); + jsonData["finalSeed"] = ctx->GetSeed(); + + // Write Hash + int index = 0; + for (uint8_t seed_value : ctx->hashIconIndexes) { + jsonData["file_hash"][index] = seed_value; + index++; + } - ctx->playthroughLocations.clear(); - ctx->playthroughBeatable = false; + WriteSettings(); + WriteExcludedLocations(); + WriteStartingInventory(); + WriteEnabledTricks(); + WriteMasterQuestDungeons(); + WriteChosenOptions(); + WritePlaythrough(); - ctx->WriteHintJson(jsonData); - WriteShuffledEntrances(); - WriteAllLocations(); + ctx->playthroughLocations.clear(); + ctx->playthroughBeatable = false; - if (!std::filesystem::exists(Ship::Context::GetPathRelativeToAppDirectory("Randomizer"))) { - std::filesystem::create_directory(Ship::Context::GetPathRelativeToAppDirectory("Randomizer")); - } + ctx->WriteHintJson(jsonData); + WriteShuffledEntrances(); + WriteAllLocations(); - std::string jsonString = jsonData.dump(4); - std::ostringstream fileNameStream; - for (uint8_t i = 0; i < ctx->hashIconIndexes.size(); i++) { - if (i) { - fileNameStream << '-'; + if (!std::filesystem::exists(Ship::Context::GetPathRelativeToAppDirectory("Randomizer"))) { + std::filesystem::create_directory(Ship::Context::GetPathRelativeToAppDirectory("Randomizer")); } - if (ctx->hashIconIndexes[i] < 10) { - fileNameStream << '0'; + + std::string jsonString = jsonData.dump(4); + std::ostringstream fileNameStream; + for (uint8_t i = 0; i < ctx->hashIconIndexes.size(); i++) { + if (i) { + fileNameStream << '-'; + } + if (ctx->hashIconIndexes[i] < 10) { + fileNameStream << '0'; + } + fileNameStream << std::to_string(ctx->hashIconIndexes[i]); } - fileNameStream << std::to_string(ctx->hashIconIndexes[i]); + std::string fileName = fileNameStream.str(); + std::ofstream jsonFile(Ship::Context::GetPathRelativeToAppDirectory( + (std::string("Randomizer/") + fileName + std::string(".json")).c_str())); + jsonFile << std::setw(4) << jsonString << std::endl; + jsonFile.close(); + + CVarSetString(CVAR_GENERAL("SpoilerLog"), + (std::string("./Randomizer/") + fileName + std::string(".json")).c_str()); + } catch (const std::exception& e) { + SPDLOG_ERROR("SpoilerLog_Write failed: {}", e.what()); } - std::string fileName = fileNameStream.str(); - std::ofstream jsonFile(Ship::Context::GetPathRelativeToAppDirectory( - (std::string("Randomizer/") + fileName + std::string(".json")).c_str())); - jsonFile << std::setw(4) << jsonString << std::endl; - jsonFile.close(); - - CVarSetString(CVAR_GENERAL("SpoilerLog"), (std::string("./Randomizer/") + fileName + std::string(".json")).c_str()); } void PlacementLog_Msg(std::string_view msg) { diff --git a/soh/soh/Enhancements/randomizer/hint.cpp b/soh/soh/Enhancements/randomizer/hint.cpp index 4f692373f79..8ac6e6d60e8 100644 --- a/soh/soh/Enhancements/randomizer/hint.cpp +++ b/soh/soh/Enhancements/randomizer/hint.cpp @@ -2,6 +2,7 @@ #include "map" #include "string" #include "SeedContext.h" +#include "soh/util.h" #include #include "static_data.h" #include "3drando/random.hpp" @@ -375,31 +376,34 @@ oJson Hint::toJSON() { auto ctx = Rando::Context::GetInstance(); nlohmann::ordered_json log = {}; if (enabled) { - log["type"] = StaticData::hintTypeNames[hintType].GetForCurrentLanguage(MF_CLEAN); + log["type"] = SohUtils::SanitizeUtf8(StaticData::hintTypeNames[hintType].GetForCurrentLanguage(MF_CLEAN)); std::vector hintMessages = GetAllMessageStrings(MF_CLEAN); if (hintMessages.size() == 1) { - log["message"] = hintMessages[0]; + log["message"] = SohUtils::SanitizeUtf8(hintMessages[0]); } else if (hintMessages.size() > 1) { - log["messages"] = hintMessages; + std::vector sanitizedMessages; + sanitizedMessages.reserve(hintMessages.size()); + for (const std::string& message : hintMessages) { + sanitizedMessages.push_back(SohUtils::SanitizeUtf8(message)); + } + log["messages"] = sanitizedMessages; } if (distribution != "") { - log["distribution"] = distribution; + log["distribution"] = SohUtils::SanitizeUtf8(distribution); } if (hintType != HINT_TYPE_FOOLISH) { if (!(StaticData::staticHintInfoMap.contains(ownKey) && StaticData::staticHintInfoMap[ownKey].targetChecks.size() > 0)) { if (locations.size() == 1) { - log["location"] = StaticData::GetLocation(locations[0]) - ->GetName(); // RANDOTODO change to CustomMessage when VB is done; + log["location"] = SohUtils::SanitizeUtf8(StaticData::GetLocation(locations[0])->GetName()); } else if (locations.size() > 1) { // If we have defaults, no need to write more std::vector locStrings = {}; for (size_t c = 0; c < locations.size(); c++) { - locStrings.push_back(StaticData::GetLocation(locations[c]) - ->GetName()); // RANDOTODO change to CustomMessage when VB is done + locStrings.push_back(SohUtils::SanitizeUtf8(StaticData::GetLocation(locations[c])->GetName())); } log["locations"] = locStrings; } @@ -408,15 +412,11 @@ oJson Hint::toJSON() { if (!(StaticData::staticHintInfoMap.contains(ownKey) && StaticData::staticHintInfoMap[ownKey].targetItems.size() > 0)) { if (items.size() == 1) { - log["item"] = StaticData::GetItemTable()[items[0]] - .GetName() - .GetEnglish(); // RANDOTODO change to CustomMessage; + log["item"] = SohUtils::SanitizeUtf8(StaticData::GetItemTable()[items[0]].GetName().GetEnglish()); } else if (items.size() > 1) { std::vector itemStrings = {}; for (size_t c = 0; c < items.size(); c++) { - itemStrings.push_back(StaticData::GetItemTable()[items[c]] - .GetName() - .GetEnglish()); // RANDOTODO change to CustomMessage + itemStrings.push_back(SohUtils::SanitizeUtf8(StaticData::GetItemTable()[items[c]].GetName().GetEnglish())); } log["items"] = itemStrings; } @@ -433,16 +433,16 @@ oJson Hint::toJSON() { } } if (areas.size() == 1) { - log["area"] = - StaticData::hintTextTable[StaticData::areaNames[areas[0]]].GetClear().GetForCurrentLanguage(MF_CLEAN); + log["area"] = SohUtils::SanitizeUtf8( + StaticData::hintTextTable[StaticData::areaNames[areas[0]]].GetClear().GetForCurrentLanguage(MF_CLEAN)); } else if (areas.size() > 0 && !(StaticData::staticHintInfoMap.contains(ownKey) && StaticData::staticHintInfoMap[ownKey].targetChecks.size() > 0)) { // If we got locations from defaults, areas are derived from them and don't need logging std::vector areaStrings = {}; for (size_t c = 0; c < areas.size(); c++) { - areaStrings.push_back( + areaStrings.push_back(SohUtils::SanitizeUtf8( StaticData::hintTextTable[StaticData::areaNames[areas[c]]].GetClear().GetForCurrentLanguage( - MF_CLEAN)); + MF_CLEAN))); } log["areas"] = areaStrings; } @@ -458,11 +458,12 @@ oJson Hint::toJSON() { } if (trials.size() == 1) { - log["trial"] = ctx->GetTrial(trials[0])->GetName().GetForCurrentLanguage(MF_CLEAN); + log["trial"] = SohUtils::SanitizeUtf8(ctx->GetTrial(trials[0])->GetName().GetForCurrentLanguage(MF_CLEAN)); } else if (trials.size() > 0) { std::vector trialStrings = {}; for (size_t c = 0; c < trials.size(); c++) { - trialStrings.push_back(ctx->GetTrial(trials[c])->GetName().GetForCurrentLanguage(MF_CLEAN)); + trialStrings.push_back( + SohUtils::SanitizeUtf8(ctx->GetTrial(trials[c])->GetName().GetForCurrentLanguage(MF_CLEAN))); } log["trials"] = trialStrings; } @@ -505,7 +506,7 @@ void Hint::logHint(oJson& jsonData) { if (enabled && (!(staticHint && (hintType == HINT_TYPE_ITEM) && ctx->GetOption(RSK_HINT_CLARITY).Is(RO_HINT_CLARITY_CLEAR)))) { // skip if not enabled or if a static hint with no possible variance - jsonData[logMap][Rando::StaticData::hintNames[ownKey].GetForCurrentLanguage(MF_CLEAN)] = toJSON(); + jsonData[logMap][SohUtils::SanitizeUtf8(Rando::StaticData::hintNames[ownKey].GetForCurrentLanguage(MF_CLEAN))] = toJSON(); } } diff --git a/soh/soh/SaveManager.cpp b/soh/soh/SaveManager.cpp index 67e250a58c3..0bded3a11c4 100644 --- a/soh/soh/SaveManager.cpp +++ b/soh/soh/SaveManager.cpp @@ -30,6 +30,16 @@ extern "C" SaveContext gSaveContext; using namespace std::string_literals; +void SaveManager::SaveData(const std::string& name, const std::string& data) { + const std::string sanitized = SohUtils::SanitizeUtf8(data); + if (name == "") { + assert((*currentJsonContext).is_array()); + (*currentJsonContext).push_back(sanitized); + } else { + (*currentJsonContext)[name.c_str()] = sanitized; + } +} + void SaveManager::WriteSaveFile(const std::filesystem::path& savePath, const uintptr_t addr, void* dramAddr, const size_t size) { std::ofstream saveFile = std::ofstream(savePath, std::fstream::in | std::fstream::out | std::fstream::binary); @@ -1150,80 +1160,90 @@ int copy_file(const char* src, const char* dst) { void SaveManager::SaveFileThreaded(int fileNum, SaveContext* saveContext, int sectionID) { saveMtx.lock(); SPDLOG_INFO("Save File - fileNum: {}", fileNum); - // Needed for first time save, hasn't changed in forever anyway - saveBlock["version"] = 1; - if (IS_RANDO) { - saveBlock["fileType"] = FILE_TYPE_SAVE_RANDO; - } else { - saveBlock["fileType"] = FILE_TYPE_SAVE_VANILLA; - } - if (sectionID == SECTION_ID_BASE) { - for (auto& sectionHandlerPair : sectionSaveHandlers) { - auto& saveFuncInfo = sectionHandlerPair.second; - // Don't call SaveFuncs for sections that aren't tied to game save - if (!saveFuncInfo.saveWithBase || (saveFuncInfo.name == "randomizer" && !IS_RANDO)) { - continue; - } - nlohmann::json& sectionBlock = saveBlock["sections"][saveFuncInfo.name]; - sectionBlock["version"] = sectionHandlerPair.second.version; - // If any save file is loaded for medatata, or a spoiler log is loaded (not sure which at this point), there - // is still data in the "randomizer" section This clears the randomizer data block if and only if the - // section being called is "randomizer" and the current save file is not a randomizer save file. - currentJsonContext = §ionBlock["data"]; - sectionHandlerPair.second.func(saveContext, sectionID, true); + try { + // Needed for first time save, hasn't changed in forever anyway + saveBlock["version"] = 1; + if (IS_RANDO) { + saveBlock["fileType"] = FILE_TYPE_SAVE_RANDO; + } else { + saveBlock["fileType"] = FILE_TYPE_SAVE_VANILLA; } - } else { - SaveFuncInfo svi = sectionSaveHandlers.find(sectionID)->second; - auto& sectionName = svi.name; - auto sectionVersion = svi.version; - // If section has a parentSection, it is a subsection. Load parentSection version and set sectionBlock to parent - // string - if (svi.parentSection != -1 && svi.parentSection < sectionIndex) { - auto parentSvi = sectionSaveHandlers.find(svi.parentSection)->second; - sectionName = parentSvi.name; - sectionVersion = parentSvi.version; + if (sectionID == SECTION_ID_BASE) { + for (auto& sectionHandlerPair : sectionSaveHandlers) { + auto& saveFuncInfo = sectionHandlerPair.second; + // Don't call SaveFuncs for sections that aren't tied to game save + if (!saveFuncInfo.saveWithBase || (saveFuncInfo.name == "randomizer" && !IS_RANDO)) { + continue; + } + nlohmann::json& sectionBlock = saveBlock["sections"][saveFuncInfo.name]; + sectionBlock["version"] = sectionHandlerPair.second.version; + // If any save file is loaded for medatata, or a spoiler log is loaded (not sure which at this point), there + // is still data in the "randomizer" section This clears the randomizer data block if and only if the + // section being called is "randomizer" and the current save file is not a randomizer save file. + + currentJsonContext = §ionBlock["data"]; + sectionHandlerPair.second.func(saveContext, sectionID, true); + } + } else { + SaveFuncInfo svi = sectionSaveHandlers.find(sectionID)->second; + auto& sectionName = svi.name; + auto sectionVersion = svi.version; + // If section has a parentSection, it is a subsection. Load parentSection version and set sectionBlock to parent + // string + if (svi.parentSection != -1 && svi.parentSection < sectionIndex) { + auto parentSvi = sectionSaveHandlers.find(svi.parentSection)->second; + sectionName = parentSvi.name; + sectionVersion = parentSvi.version; + } + nlohmann::json& sectionBlock = saveBlock["sections"][sectionName]; + sectionBlock["version"] = sectionVersion; + currentJsonContext = §ionBlock["data"]; + svi.func(saveContext, sectionID, false); } - nlohmann::json& sectionBlock = saveBlock["sections"][sectionName]; - sectionBlock["version"] = sectionVersion; - currentJsonContext = §ionBlock["data"]; - svi.func(saveContext, sectionID, false); - } - std::filesystem::path fileName = GetFileName(fileNum); - std::filesystem::path tempFile = GetFileTempName(fileNum); + std::filesystem::path fileName = GetFileName(fileNum); + std::filesystem::path tempFile = GetFileTempName(fileNum); - if (std::filesystem::exists(tempFile)) { - std::filesystem::remove(tempFile); - } + if (std::filesystem::exists(tempFile)) { + std::filesystem::remove(tempFile); + } #if defined(__SWITCH__) || defined(__WIIU__) - FILE* w = fopen(tempFile.c_str(), "w"); - std::string json_string = saveBlock.dump(1); - fwrite(json_string.c_str(), sizeof(char), json_string.length(), w); - fclose(w); + FILE* w = fopen(tempFile.c_str(), "w"); + std::string json_string = saveBlock.dump(1); + fwrite(json_string.c_str(), sizeof(char), json_string.length(), w); + fclose(w); #else - std::ofstream output(tempFile); - output << std::setw(1) << saveBlock << std::endl; - output.close(); + std::ofstream output(tempFile); + output << std::setw(1) << saveBlock << std::endl; + output.close(); #endif #if defined(__SWITCH__) || defined(__WIIU__) - if (std::filesystem::exists(fileName)) { - std::filesystem::remove(fileName); - } - copy_file(tempFile.c_str(), fileName.c_str()); - if (std::filesystem::exists(tempFile)) { - std::filesystem::remove(tempFile); - } + if (std::filesystem::exists(fileName)) { + std::filesystem::remove(fileName); + } + copy_file(tempFile.c_str(), fileName.c_str()); + if (std::filesystem::exists(tempFile)) { + std::filesystem::remove(tempFile); + } #else - std::filesystem::rename(tempFile, fileName); + std::filesystem::rename(tempFile, fileName); #endif - delete saveContext; - InitMeta(fileNum); - GameInteractor::Instance->ExecuteHooks(fileNum, sectionID); - SPDLOG_INFO("Save File Finish - fileNum: {}", fileNum); + delete saveContext; + saveContext = nullptr; + InitMeta(fileNum); + GameInteractor::Instance->ExecuteHooks(fileNum, sectionID); + SPDLOG_INFO("Save File Finish - fileNum: {}", fileNum); + } catch (const std::exception& e) { + SPDLOG_ERROR("SaveFileThreaded failed for fileNum {}: {}", fileNum, e.what()); + if (saveContext != nullptr) { + delete saveContext; + } + } + saveMtx.unlock(); } diff --git a/soh/soh/SaveManager.h b/soh/soh/SaveManager.h index 80f58b915e5..0e3bebacd74 100644 --- a/soh/soh/SaveManager.h +++ b/soh/soh/SaveManager.h @@ -119,6 +119,8 @@ class SaveManager { } } + void SaveData(const std::string& name, const std::string& data); + // In the SaveArrayFunc func, the name must be "" to save to the array. using SaveArrayFunc = std::function; void SaveArray(const std::string& name, const size_t size, SaveArrayFunc func); diff --git a/soh/soh/util.cpp b/soh/soh/util.cpp index 7efa4b7d160..fd9409b26f0 100644 --- a/soh/soh/util.cpp +++ b/soh/soh/util.cpp @@ -765,6 +765,59 @@ std::string SohUtils::Sanitize(std::string stringValue) { return stringValue; } +std::string SohUtils::SanitizeUtf8(std::string value) { + auto isUtf8Continuation = [](unsigned char byte) { return (byte & 0xC0) == 0x80; }; + + auto utf8SequenceLength = [](unsigned char lead) -> size_t { + if (lead < 0x80) { + return 1; + } + if ((lead & 0xE0) == 0xC0) { + return 2; + } + if ((lead & 0xF0) == 0xE0) { + return 3; + } + if ((lead & 0xF8) == 0xF0) { + return 4; + } + return 0; + }; + + std::string sanitized; + sanitized.reserve(value.size()); + + for (size_t i = 0; i < value.size();) { + const unsigned char lead = static_cast(value[i]); + const size_t sequenceLength = utf8SequenceLength(lead); + + if (sequenceLength == 0 || i + sequenceLength > value.size()) { + sanitized.push_back('?'); + i++; + continue; + } + + bool valid = true; + for (size_t j = 1; j < sequenceLength; j++) { + if (!isUtf8Continuation(static_cast(value[i + j]))) { + valid = false; + break; + } + } + + if (!valid) { + sanitized.push_back('?'); + i++; + continue; + } + + sanitized.append(value, i, sequenceLength); + i += sequenceLength; + } + + return sanitized; +} + size_t SohUtils::CopyStringToCharBuffer(char* buffer, const std::string& source, const size_t maxBufferSize) { if (!source.empty() && maxBufferSize > 0) { memset(buffer, 0, maxBufferSize); diff --git a/soh/soh/util.h b/soh/soh/util.h index 2058a344290..bd8c46fd4a7 100644 --- a/soh/soh/util.h +++ b/soh/soh/util.h @@ -20,6 +20,9 @@ void CopyStringToCharArray(char* destination, std::string source, size_t size); std::string Sanitize(std::string stringValue); +// Strips invalid UTF-8 sequences so JSON serializers accept the string. +std::string SanitizeUtf8(std::string value); + // Copies a string into a char buffer up to maxBufferSize characters. This does NOT insert a null terminator // on the end, as this is used for in-game messages which are not null-terminated. size_t CopyStringToCharBuffer(char* buffer, const std::string& source, size_t maxBufferSize);