// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team // SPDX-License-Identifier: GPL-3.0+ #include "GameDatabase.h" #include "GS/GS.h" #include "Host.h" #include "IconsFontAwesome.h" #include "vtlb.h" #include "common/Console.h" #include "common/EnumOps.h" #include "common/Error.h" #include "common/FileSystem.h" #include "common/Path.h" #include "common/StringUtil.h" #include "common/Timer.h" #include "common/YAML.h" #include #include "fmt/format.h" #include "fmt/ranges.h" #include #include #include namespace GameDatabaseSchema { static const char* getHWFixName(GSHWFixId id); static std::optional parseHWFixName(const std::string_view name); static bool isUserHackHWFix(GSHWFixId id); } // namespace GameDatabaseSchema namespace GameDatabase { static void populateEntry(GameDatabaseSchema::GameEntry& gameEntry, const std::string_view serial, const ryml::NodeRef& node, bool is_override); static void parseAndInsert(const std::string_view serial, const ryml::NodeRef& node); static void mergeOverride(const std::string_view serial, const ryml::NodeRef& node); static void loadFile(const std::string& path, const std::string& name, bool is_override); static void initDatabase(); } // namespace GameDatabase static constexpr char GAMEDB_YAML_FILE_NAME[] = "GameIndex.yaml"; // ARMSX2 GameDB overrides — layered on top of upstream GameIndex.yaml after it // loads. Entries either add NEW serials upstream doesn't cover, or PARTIAL- // override existing upstream entries: scalar fields replace upstream when // present; list fields (gameFixes / speedHacks / gsHWFixes / memcardFilters / // patches / dynaPatches) get cleared when the override specifies them so we // REPLACE rather than append. The file is optional — missing is a silent skip. static constexpr char GAMEDB_OVERRIDE_YAML_FILE_NAME[] = "armsx2_overrides.yaml"; static std::unordered_map s_game_db; static std::once_flag s_load_once_flag; std::string GameDatabaseSchema::GameEntry::memcardFiltersAsString() const { return fmt::to_string(fmt::join(memcardFilters, "/")); } const std::string* GameDatabaseSchema::GameEntry::findPatch(u32 crc) const { if (crc == 0) return nullptr; auto it = patches.find(crc); if (it != patches.end()) return &it->second; it = patches.find(0); if (it != patches.end()) return &it->second; return nullptr; } const char* GameDatabaseSchema::GameEntry::compatAsString() const { switch (compat) { case GameDatabaseSchema::Compatibility::Perfect: return "Perfect"; case GameDatabaseSchema::Compatibility::Playable: return "Playable"; case GameDatabaseSchema::Compatibility::InGame: return "In-Game"; case GameDatabaseSchema::Compatibility::Menu: return "Menu"; case GameDatabaseSchema::Compatibility::Intro: return "Intro"; case GameDatabaseSchema::Compatibility::Nothing: return "Nothing"; default: return "Unknown"; } } void GameDatabase::populateEntry(GameDatabaseSchema::GameEntry& gameEntry, const std::string_view serial, const ryml::NodeRef& node, bool is_override) { if (node.has_child("name")) { node["name"] >> gameEntry.name; } if (node.has_child("name-sort")) { node["name-sort"] >> gameEntry.name_sort; } if (node.has_child("name-en")) { node["name-en"] >> gameEntry.name_en; } if (node.has_child("region")) { node["region"] >> gameEntry.region; } if (node.has_child("compat")) { int val = 0; node["compat"] >> val; gameEntry.compat = static_cast(val); } if (node.has_child("roundModes")) { if (node["roundModes"].has_child("eeRoundMode")) { int eeVal = -1; node["roundModes"]["eeRoundMode"] >> eeVal; if (eeVal >= 0 && eeVal < static_cast(FPRoundMode::MaxCount)) gameEntry.eeRoundMode = static_cast(eeVal); else Console.Error(fmt::format("GameDB: Invalid EE round mode '{}', specified for serial: '{}'.", eeVal, serial)); } if (node["roundModes"].has_child("eeDivRoundMode")) { int eeVal = -1; node["roundModes"]["eeDivRoundMode"] >> eeVal; if (eeVal >= 0 && eeVal < static_cast(FPRoundMode::MaxCount)) gameEntry.eeDivRoundMode = static_cast(eeVal); else Console.Error(fmt::format("GameDB: Invalid EE division round mode '{}', specified for serial: '{}'.", eeVal, serial)); } if (node["roundModes"].has_child("vuRoundMode")) { int vuVal = -1; node["roundModes"]["vuRoundMode"] >> vuVal; if (vuVal >= 0 && vuVal < static_cast(FPRoundMode::MaxCount)) { gameEntry.vu0RoundMode = static_cast(vuVal); gameEntry.vu1RoundMode = static_cast(vuVal); } else { Console.Error(fmt::format("GameDB: Invalid VU round mode '{}', specified for serial: '{}'.", vuVal, serial)); } } if (node["roundModes"].has_child("vu0RoundMode")) { int vuVal = -1; node["roundModes"]["vu0RoundMode"] >> vuVal; if (vuVal >= 0 && vuVal < static_cast(FPRoundMode::MaxCount)) gameEntry.vu0RoundMode = static_cast(vuVal); else Console.Error(fmt::format("GameDB: Invalid VU0 round mode '{}', specified for serial: '{}'.", vuVal, serial)); } if (node["roundModes"].has_child("vu1RoundMode")) { int vuVal = -1; node["roundModes"]["vu1RoundMode"] >> vuVal; if (vuVal >= 0 && vuVal < static_cast(FPRoundMode::MaxCount)) gameEntry.vu1RoundMode = static_cast(vuVal); else Console.Error(fmt::format("GameDB: Invalid VU1 round mode '{}', specified for serial: '{}'.", vuVal, serial)); } } if (node.has_child("clampModes")) { if (node["clampModes"].has_child("eeClampMode")) { int eeVal = -1; node["clampModes"]["eeClampMode"] >> eeVal; gameEntry.eeClampMode = static_cast(eeVal); } if (node["clampModes"].has_child("vuClampMode")) { int vuVal = -1; node["clampModes"]["vuClampMode"] >> vuVal; gameEntry.vu0ClampMode = static_cast(vuVal); gameEntry.vu1ClampMode = static_cast(vuVal); } if (node["clampModes"].has_child("vu0ClampMode")) { int vuVal = -1; node["clampModes"]["vu0ClampMode"] >> vuVal; gameEntry.vu0ClampMode = static_cast(vuVal); } if (node["clampModes"].has_child("vu1ClampMode")) { int vuVal = -1; node["clampModes"]["vu1ClampMode"] >> vuVal; gameEntry.vu1ClampMode = static_cast(vuVal); } } // Validate game fixes, invalid ones will be dropped! if (node.has_child("gameFixes") && node["gameFixes"].has_children()) { // Override REPLACES upstream's list, it does not append. if (is_override) gameEntry.gameFixes.clear(); for (const auto& n : node["gameFixes"].children()) { bool fixValidated = false; auto fix = std::string(n.val().str, n.val().len); // Enum values don't end with Hack, but gamedb does, so remove it before comparing. if (fix.ends_with("Hack")) { fix.erase(fix.size() - 4); for (GamefixId id = GamefixId_FIRST; id < GamefixId_COUNT; id = static_cast(enum_cast(id) + 1)) { if (fix.compare(Pcsx2Config::GamefixOptions::GetGameFixName(id)) == 0 && std::find(gameEntry.gameFixes.begin(), gameEntry.gameFixes.end(), id) == gameEntry.gameFixes.end()) { gameEntry.gameFixes.push_back(id); fixValidated = true; break; } } } if (!fixValidated) { Console.Error(fmt::format("GameDB: Invalid gamefix: '{}', specified for serial: '{}'. Dropping!", fix, serial)); } } } if (node.has_child("speedHacks") && node["speedHacks"].has_children()) { if (is_override) gameEntry.speedHacks.clear(); for (const auto& n : node["speedHacks"].children()) { const std::string_view id_view = std::string_view(n.key().str, n.key().len); const std::string_view value_view = std::string_view(n.val().str, n.val().len); const std::optional id = Pcsx2Config::SpeedhackOptions::ParseSpeedHackName(id_view); const std::optional value = StringUtil::FromChars(value_view); if (id.has_value() && value.has_value() && std::none_of(gameEntry.speedHacks.begin(), gameEntry.speedHacks.end(), [&id](const auto& it) { return it.first == id.value(); })) { gameEntry.speedHacks.emplace_back(id.value(), value.value()); } else { Console.Error(fmt::format("GameDB: Invalid speedhack: '{}={}', specified for serial: '{}'. Dropping!", id_view, value_view, serial)); } } } if (node.has_child("gsHWFixes")) { if (is_override) gameEntry.gsHWFixes.clear(); for (const auto& n : node["gsHWFixes"].children()) { const std::string_view id_name(n.key().data(), n.key().size()); std::optional id = GameDatabaseSchema::parseHWFixName(id_name); std::optional value; if (id.has_value() && (id.value() == GameDatabaseSchema::GSHWFixId::GetSkipCount || id.value() == GameDatabaseSchema::GSHWFixId::BeforeDraw || id.value() == GameDatabaseSchema::GSHWFixId::MoveHandler)) { const std::string_view str_value(n.has_val() ? std::string_view(n.val().data(), n.val().size()) : std::string_view()); if (id.value() == GameDatabaseSchema::GSHWFixId::GetSkipCount) value = GSLookupGetSkipCountFunctionId(str_value); else if (id.value() == GameDatabaseSchema::GSHWFixId::BeforeDraw) value = GSLookupBeforeDrawFunctionId(str_value); else if (id.value() == GameDatabaseSchema::GSHWFixId::MoveHandler) value = GSLookupMoveHandlerFunctionId(str_value); if (value.value_or(-1) < 0) { Console.Error(fmt::format("GameDB: Invalid GS HW Fix Value for '{}' in '{}': '{}'", id_name, serial, str_value)); continue; } } else { value = n.has_val() ? StringUtil::FromChars(std::string_view(n.val().data(), n.val().size())) : 1; } if (!id.has_value() || !value.has_value()) { Console.Error(fmt::format("GameDB: Invalid GS HW Fix: '{}' specified for serial '{}'. Dropping!", id_name, serial)); continue; } gameEntry.gsHWFixes.emplace_back(id.value(), value.value()); } } // Memory Card Filters - Store as a vector to allow flexibility in the future // - currently they are used as a '\n' delimited string in the app if (node.has_child("memcardFilters") && node["memcardFilters"].has_children()) { if (is_override) gameEntry.memcardFilters.clear(); for (const auto& n : node["memcardFilters"].children()) { auto memcardFilter = std::string(n.val().str, n.val().len); gameEntry.memcardFilters.emplace_back(std::move(memcardFilter)); } } // Game Patches if (node.has_child("patches") && node["patches"].has_children()) { // Patches are keyed by CRC — clearing on override means upstream's // CRC-keyed patches are dropped when the override ships its own block. if (is_override) gameEntry.patches.clear(); for (const auto& n : node["patches"].children()) { // use a crc of 0 for default patches const std::string_view crc_str(n.key().str, n.key().len); const std::optional crc = (StringUtil::compareNoCase(crc_str, "default")) ? std::optional(0) : StringUtil::FromChars(crc_str, 16); if (!crc.has_value()) { Console.Error(fmt::format("GameDB: Invalid CRC '{}' found for serial: '{}'. Skipping!", crc_str, serial)); continue; } if (gameEntry.patches.find(crc.value()) != gameEntry.patches.end()) { Console.Error(fmt::format("GameDB: Duplicate CRC '{}' found for serial: '{}'. Skipping, CRCs are case-insensitive!", crc_str, serial)); continue; } std::string patch; if (n.has_child("content")) n["content"] >> patch; gameEntry.patches.emplace(crc.value(), std::move(patch)); } } if (node.has_child("dynaPatches") && node["dynaPatches"].has_children()) { if (is_override) gameEntry.dynaPatches.clear(); for (const auto& n : node["dynaPatches"].children()) { Patch::DynamicPatch patch; if (n.has_child("pattern") && n["pattern"].has_children()) { for (const auto& db_pattern : n["pattern"].children()) { Patch::DynamicPatchEntry entry; db_pattern["offset"] >> entry.offset; db_pattern["value"] >> entry.value; patch.pattern.push_back(entry); } for (const auto& db_replacement : n["replacement"].children()) { Patch::DynamicPatchEntry entry; db_replacement["offset"] >> entry.offset; db_replacement["value"] >> entry.value; patch.replacement.push_back(entry); } } gameEntry.dynaPatches.push_back(patch); } } } void GameDatabase::parseAndInsert(const std::string_view serial, const ryml::NodeRef& node) { GameDatabaseSchema::GameEntry gameEntry; populateEntry(gameEntry, serial, node, /*is_override=*/false); s_game_db.emplace(std::string(serial), std::move(gameEntry)); } void GameDatabase::mergeOverride(const std::string_view serial, const ryml::NodeRef& node) { // Fetch-or-create: when the serial already exists (a PARTIAL override of // upstream), the override's scalars replace and its list fields clear-then- // replace; when it's a NEW serial, this creates it from scratch, same shape // as a normal entry. auto& gameEntry = s_game_db[std::string(serial)]; populateEntry(gameEntry, serial, node, /*is_override=*/true); } static const char* s_round_modes[static_cast(FPRoundMode::MaxCount)] = { "Nearest", "NegativeInfinity", "PositiveInfinity", "Chop"}; static const char* s_gs_hw_fix_names[] = { "autoFlush", "cpuFramebufferConversion", "readTCOnClose", "disableDepthSupport", "preloadFrameData", "disablePartialInvalidation", "textureInsideRT", "limit24BitDepth", "alignSprite", "mergeSprite", "mipmap", "accurateAlphaTest", "forceEvenSpritePosition", "bilinearUpscale", "nativePaletteDraw", "estimateTextureRegion", "drawBuffering", "PCRTCOffsets", "PCRTCOverscan", "coalesceRenderPasses", "trilinearFiltering", "skipDrawStart", "skipDrawEnd", "halfPixelOffset", "roundSprite", "nativeScaling", "texturePreloading", "deinterlace", "cpuSpriteRenderBW", "cpuSpriteRenderLevel", "cpuCLUTRender", "gpuTargetCLUT", "gpuPaletteConversion", "minimumBlendingLevel", "maximumBlendingLevel", "recommendedBlendingLevel", "recommendedAccurateAlphaTest", "recommendedHWAA1", "hwDownloadMode", "getSkipCount", "beforeDraw", "moveHandler", }; static_assert(std::size(s_gs_hw_fix_names) == static_cast(GameDatabaseSchema::GSHWFixId::Count), "HW fix name lookup is correct size"); const char* GameDatabaseSchema::getHWFixName(GSHWFixId id) { return s_gs_hw_fix_names[static_cast(id)]; } static std::optional GameDatabaseSchema::parseHWFixName(const std::string_view name) { for (u32 i = 0; i < std::size(s_gs_hw_fix_names); i++) { if (name.compare(s_gs_hw_fix_names[i]) == 0) return static_cast(i); } return std::nullopt; } bool GameDatabaseSchema::isUserHackHWFix(GSHWFixId id) { switch (id) { case GSHWFixId::Deinterlace: case GSHWFixId::Mipmap: case GSHWFixId::TexturePreloading: case GSHWFixId::TrilinearFiltering: case GSHWFixId::MinimumBlendingLevel: case GSHWFixId::MaximumBlendingLevel: case GSHWFixId::RecommendedBlendingLevel: case GSHWFixId::PCRTCOffsets: case GSHWFixId::PCRTCOverscan: case GSHWFixId::GetSkipCount: case GSHWFixId::BeforeDraw: case GSHWFixId::MoveHandler: return false; default: return true; } } // Which fixes a player can claim for themselves. Anything not listed keeps the old // behaviour, so the database still wins on the ones no UI exposes. static std::optional UserHackOverrideForHWFix(GameDatabaseSchema::GSHWFixId id) { switch (id) { case GameDatabaseSchema::GSHWFixId::AlignSprite: return GSUserHackOverride::AlignSprite; case GameDatabaseSchema::GSHWFixId::MergeSprite: return GSUserHackOverride::MergeSprite; case GameDatabaseSchema::GSHWFixId::RoundSprite: return GSUserHackOverride::RoundSprite; case GameDatabaseSchema::GSHWFixId::HalfPixelOffset: return GSUserHackOverride::HalfPixelOffset; case GameDatabaseSchema::GSHWFixId::ForceEvenSpritePosition: return GSUserHackOverride::ForceEvenSpritePosition; case GameDatabaseSchema::GSHWFixId::NativeScaling: return GSUserHackOverride::NativeScaling; case GameDatabaseSchema::GSHWFixId::NativePaletteDraw: return GSUserHackOverride::NativePaletteDraw; case GameDatabaseSchema::GSHWFixId::BilinearUpscale: return GSUserHackOverride::BilinearHack; case GameDatabaseSchema::GSHWFixId::AutoFlush: return GSUserHackOverride::AutoFlush; case GameDatabaseSchema::GSHWFixId::TextureInsideRT: return GSUserHackOverride::TextureInsideRt; case GameDatabaseSchema::GSHWFixId::PreloadFrameData: return GSUserHackOverride::PreloadFrameData; case GameDatabaseSchema::GSHWFixId::DisablePartialInvalidation: return GSUserHackOverride::DisablePartialInvalidation; case GameDatabaseSchema::GSHWFixId::GPUPaletteConversion: return GSUserHackOverride::GPUPaletteConversion; case GameDatabaseSchema::GSHWFixId::DisableDepthSupport: return GSUserHackOverride::DisableDepthSupport; case GameDatabaseSchema::GSHWFixId::CPUFramebufferConversion: return GSUserHackOverride::CPUFBConversion; case GameDatabaseSchema::GSHWFixId::Limit24BitDepth: return GSUserHackOverride::Limit24BitDepth; case GameDatabaseSchema::GSHWFixId::EstimateTextureRegion: return GSUserHackOverride::EstimateTextureRegion; case GameDatabaseSchema::GSHWFixId::DrawBuffering: return GSUserHackOverride::DrawBuffering; case GameDatabaseSchema::GSHWFixId::CPUSpriteRenderBW: return GSUserHackOverride::CPUSpriteRenderBW; case GameDatabaseSchema::GSHWFixId::CPUSpriteRenderLevel: return GSUserHackOverride::CPUSpriteRenderLevel; case GameDatabaseSchema::GSHWFixId::CPUCLUTRender: return GSUserHackOverride::CPUCLUTRender; case GameDatabaseSchema::GSHWFixId::GPUTargetCLUT: return GSUserHackOverride::GPUTargetCLUT; default: return std::nullopt; } } void GameDatabaseSchema::GameEntry::applyGameFixes(Pcsx2Config& config, bool applyAuto) const { // Only apply core game fixes if the user has enabled them. if (!applyAuto) Console.Warning("GameDB: Game Fixes are disabled"); if (eeRoundMode < FPRoundMode::MaxCount) { if (applyAuto) { Console.WriteLn("GameDB: Changing EE/FPU roundmode to %d [%s]", eeRoundMode, s_round_modes[static_cast(eeRoundMode)]); config.Cpu.FPUFPCR.SetRoundMode(eeRoundMode); } else { Console.Warning("GameDB: Skipping changing EE/FPU roundmode to %d [%s]", eeRoundMode, s_round_modes[static_cast(eeRoundMode)]); } } if (eeDivRoundMode < FPRoundMode::MaxCount) { if (applyAuto) { Console.WriteLn("GameDB: Changing EE/FPU divison roundmode to %d [%s]", eeRoundMode, s_round_modes[static_cast(eeDivRoundMode)]); config.Cpu.FPUDivFPCR.SetRoundMode(eeDivRoundMode); } else { Console.Warning("GameDB: Skipping changing EE/FPU roundmode to %d [%s]", eeRoundMode, s_round_modes[static_cast(eeRoundMode)]); } } if (vu0RoundMode < FPRoundMode::MaxCount) { if (applyAuto) { Console.WriteLn("GameDB: Changing VU0 roundmode to %d [%s]", vu0RoundMode, s_round_modes[static_cast(vu0RoundMode)]); config.Cpu.VU0FPCR.SetRoundMode(vu0RoundMode); } else { Console.Warning("GameDB: Skipping changing VU0 roundmode to %d [%s]", vu0RoundMode, s_round_modes[static_cast(vu0RoundMode)]); } } if (vu1RoundMode < FPRoundMode::MaxCount) { if (applyAuto) { Console.WriteLn("GameDB: Changing VU1 roundmode to %d [%s]", vu1RoundMode, s_round_modes[static_cast(vu1RoundMode)]); config.Cpu.VU1FPCR.SetRoundMode(vu1RoundMode); } else { Console.Warning("GameDB: Skipping changing VU1 roundmode to %d [%s]", vu1RoundMode, s_round_modes[static_cast(vu1RoundMode)]); } } if (eeClampMode != GameDatabaseSchema::ClampMode::Undefined) { const int clampMode = enum_cast(eeClampMode); if (applyAuto) { Console.WriteLn("GameDB: Changing EE/FPU clamp mode [mode=%d]", clampMode); config.Cpu.Recompiler.fpuOverflow = (clampMode >= 1); config.Cpu.Recompiler.fpuExtraOverflow = (clampMode >= 2); config.Cpu.Recompiler.fpuFullMode = (clampMode >= 3); config.Cpu.Recompiler.fpuExactMode = (clampMode >= 4); } else Console.Warning("GameDB: Skipping changing EE/FPU clamp mode [mode=%d]", clampMode); } if (vu0ClampMode != GameDatabaseSchema::ClampMode::Undefined) { const int clampMode = enum_cast(vu0ClampMode); if (applyAuto) { Console.WriteLn("GameDB: Changing VU0 clamp mode [mode=%d]", clampMode); config.Cpu.Recompiler.vu0Overflow = (clampMode >= 1); config.Cpu.Recompiler.vu0ExtraOverflow = (clampMode >= 2); config.Cpu.Recompiler.vu0SignOverflow = (clampMode >= 3); } else Console.Warning("GameDB: Skipping changing VU0 clamp mode [mode=%d]", clampMode); } if (vu1ClampMode != GameDatabaseSchema::ClampMode::Undefined) { const int clampMode = enum_cast(vu1ClampMode); if (applyAuto) { Console.WriteLn("GameDB: Changing VU1 clamp mode [mode=%d]", clampMode); config.Cpu.Recompiler.vu1Overflow = (clampMode >= 1); config.Cpu.Recompiler.vu1ExtraOverflow = (clampMode >= 2); config.Cpu.Recompiler.vu1SignOverflow = (clampMode >= 3); } else Console.Warning("GameDB: Skipping changing VU1 clamp mode [mode=%d]", clampMode); } // TODO - config - this could be simplified with maps instead of bitfields and enums for (const auto& it : speedHacks) { if (!applyAuto) { Console.Warning("GameDB: Skipping setting Speedhack '%s' to [mode=%d]", Pcsx2Config::SpeedhackOptions::GetSpeedHackName(it.first), it.second); continue; } // Legacy note - speedhacks are setup in the GameDB as integer values, but // are effectively booleans like the gamefixes config.Speedhacks.Set(it.first, it.second); Console.WriteLn("GameDB: Setting Speedhack '%s' to [mode=%d]", Pcsx2Config::SpeedhackOptions::GetSpeedHackName(it.first), it.second); } // TODO - config - this could be simplified with maps instead of bitfields and enums for (const GamefixId id : gameFixes) { if (!applyAuto) { Console.Warning("GameDB: Skipping Gamefix: %s", Pcsx2Config::GamefixOptions::GetGameFixName(id)); continue; } // if the fix is present, it is said to be enabled config.Gamefixes.Set(id, true); Console.WriteLn("GameDB: Enabled Gamefix: %s", Pcsx2Config::GamefixOptions::GetGameFixName(id)); // The LUT is only used for 1 game so we allocate it only when the gamefix is enabled (save 4MB) if (id == Fix_GoemonTlbMiss && true) vtlb_Alloc_Ppmap(); } } bool GameDatabaseSchema::GameEntry::configMatchesHWFix(const Pcsx2Config::GSOptions& config, GSHWFixId id, int value) { switch (id) { case GSHWFixId::AutoFlush: return (static_cast(config.UserHacks_AutoFlush) == value); case GSHWFixId::CPUFramebufferConversion: return (static_cast(config.UserHacks_CPUFBConversion) == value); case GSHWFixId::FlushTCOnClose: return (static_cast(config.UserHacks_ReadTCOnClose) == value); case GSHWFixId::DisableDepthSupport: return (static_cast(config.UserHacks_DisableDepthSupport) == value); case GSHWFixId::PreloadFrameData: return (static_cast(config.PreloadFrameWithGSData) == value); case GSHWFixId::DisablePartialInvalidation: return (static_cast(config.UserHacks_DisablePartialInvalidation) == value); case GSHWFixId::TextureInsideRT: return (static_cast(config.UserHacks_TextureInsideRt) == value); case GSHWFixId::Limit24BitDepth: return (static_cast(config.UserHacks_Limit24BitDepth) == value); case GSHWFixId::AlignSprite: return (config.UpscaleMultiplier <= 1.0f || static_cast(config.UserHacks_AlignSpriteX) == value); case GSHWFixId::MergeSprite: return (config.UpscaleMultiplier <= 1.0f || static_cast(config.UserHacks_MergePPSprite) == value); case GSHWFixId::ForceEvenSpritePosition: return (config.UpscaleMultiplier <= 1.0f || static_cast(config.UserHacks_ForceEvenSpritePosition) == value); case GSHWFixId::BilinearUpscale: return (config.UpscaleMultiplier <= 1.0f || static_cast(config.UserHacks_BilinearHack) == value); case GSHWFixId::NativePaletteDraw: return (config.UpscaleMultiplier <= 1.0f || static_cast(config.UserHacks_NativePaletteDraw) == value); case GSHWFixId::EstimateTextureRegion: return (static_cast(config.UserHacks_EstimateTextureRegion) == value); case GSHWFixId::DrawBuffering: return (static_cast(config.UserHacks_DrawBuffering) == value); case GSHWFixId::PCRTCOffsets: return (static_cast(config.PCRTCOffsets) == value); case GSHWFixId::PCRTCOverscan: return (static_cast(config.PCRTCOverscan) == value); case GSHWFixId::CoalesceRenderPasses: return (static_cast(config.CoalesceRenderPasses) == value); case GSHWFixId::Mipmap: return (static_cast(config.HWMipmap) == value); case GSHWFixId::AccurateAlphaTest: return (static_cast(config.HWAccurateAlphaTest) == value); case GSHWFixId::TrilinearFiltering: return (config.TriFilter == TriFiltering::Automatic || static_cast(config.TriFilter) == value); case GSHWFixId::SkipDrawStart: return (config.SkipDrawStart == value); case GSHWFixId::SkipDrawEnd: return (config.SkipDrawEnd == value); case GSHWFixId::HalfPixelOffset: return (config.UpscaleMultiplier <= 1.0f || config.UserHacks_HalfPixelOffset == static_cast(value)); case GSHWFixId::RoundSprite: return (config.UpscaleMultiplier <= 1.0f || config.UserHacks_RoundSprite == value); case GSHWFixId::NativeScaling: return (config.UpscaleMultiplier <= 1.0f || static_cast(config.UserHacks_NativeScaling) == value); case GSHWFixId::TexturePreloading: return (static_cast(config.TexturePreloading) <= value); case GSHWFixId::Deinterlace: return (config.InterlaceMode == GSInterlaceMode::Automatic || static_cast(config.InterlaceMode) == value); case GSHWFixId::HWDownloadMode: // A non-default user choice already "matches" (we never override it — see the apply switch). return (config.HWDownloadMode != GSHardwareDownloadMode::Enabled || static_cast(config.HWDownloadMode) == value); case GSHWFixId::CPUSpriteRenderBW: return (config.UserHacks_CPUSpriteRenderBW == value); case GSHWFixId::CPUSpriteRenderLevel: return (config.UserHacks_CPUSpriteRenderLevel == value); case GSHWFixId::CPUCLUTRender: return (config.UserHacks_CPUCLUTRender == value); case GSHWFixId::GPUTargetCLUT: return (static_cast(config.UserHacks_GPUTargetCLUTMode) == value); case GSHWFixId::GPUPaletteConversion: return (config.GPUPaletteConversion == ((value > 1) ? (config.TexturePreloading == TexturePreloadingLevel::Full) : (value != 0))); case GSHWFixId::MinimumBlendingLevel: return (static_cast(config.AccurateBlendingUnit) >= value); case GSHWFixId::MaximumBlendingLevel: return (static_cast(config.AccurateBlendingUnit) <= value); case GSHWFixId::RecommendedBlendingLevel: return true; case GSHWFixId::GetSkipCount: return (static_cast(config.GetSkipCountFunctionId) == value); case GSHWFixId::BeforeDraw: return (static_cast(config.BeforeDrawFunctionId) == value); case GSHWFixId::MoveHandler: return (static_cast(config.MoveHandlerFunctionId) == value); default: return false; } } void GameDatabaseSchema::GameEntry::applyGSHardwareFixes(Pcsx2Config::GSOptions& config) const { std::string disabled_fixes; // Only apply GS HW fixes if the user hasn't manually enabled HW fixes. const bool apply_auto_fixes = !config.ManualUserHacks; const bool is_sw_renderer = EmuConfig.GS.Renderer == GSRendererType::SW; if (!apply_auto_fixes) Console.Warning("GameDB: Manual GS hardware renderer fixes are enabled, not using automatic hardware renderer fixes from GameDB."); for (const auto& [id, value] : gsHWFixes) { // A pin is manual mode for one fix instead of all of them, so it takes the same // road out: the player's value stays and this one gets named in the warning. const std::optional pin = UserHackOverrideForHWFix(id); const bool pinned = pin.has_value() && config.IsUserHackPinned(pin.value()); if (isUserHackHWFix(id) && (!apply_auto_fixes || pinned)) { if (configMatchesHWFix(config, id, value)) continue; Console.Warning("GameDB: Skipping GS Hardware Fix: %s to [mode=%d]", getHWFixName(id), value); fmt::format_to(std::back_inserter(disabled_fixes), "{} {} = {}", disabled_fixes.empty() ? " " : "\n ", getHWFixName(id), value); continue; } switch (id) { case GSHWFixId::AutoFlush: { if (value >= 0 && value <= static_cast(GSHWAutoFlushLevel::Enabled)) config.UserHacks_AutoFlush = static_cast(value); } break; case GSHWFixId::CPUFramebufferConversion: config.UserHacks_CPUFBConversion = (value > 0); break; case GSHWFixId::FlushTCOnClose: config.UserHacks_ReadTCOnClose = (value > 0); break; case GSHWFixId::DisableDepthSupport: config.UserHacks_DisableDepthSupport = (value > 0); break; case GSHWFixId::PreloadFrameData: config.PreloadFrameWithGSData = (value > 0); break; case GSHWFixId::HWDownloadMode: // GameDB carries the recommended GS Hardware Download Mode as a DEFAULT only: // it fills in for games the player hasn't touched, but must never override a // deliberate choice (their per-game/global Hardware Download Mode wins, since // LoadCoreSettings runs before this). So only apply when the current value is // still the default (Enabled). Enable manual HW fixes to force Accurate back. // The bound is a RANGE check over the raw wire value, not an ordering by // accuracy (see the GSHardwareDownloadMode comment in Config.h) — so it has // to name the LAST enumerator. Asynchronous was appended after Disabled, so // bounding at Disabled silently discarded a GameDB entry asking for it: the // parser accepted the value and this dropped it with no diagnostic. if (config.HWDownloadMode == GSHardwareDownloadMode::Enabled && value > static_cast(GSHardwareDownloadMode::Enabled) && value <= static_cast(GSHardwareDownloadMode::Asynchronous)) { config.HWDownloadMode = static_cast(value); } break; case GSHWFixId::DisablePartialInvalidation: config.UserHacks_DisablePartialInvalidation = (value > 0); break; case GSHWFixId::TextureInsideRT: { if (value >= 0 && value <= static_cast(GSTextureInRtMode::MergeTargets)) config.UserHacks_TextureInsideRt = static_cast(value); } break; case GSHWFixId::Limit24BitDepth: { if (value >= 0 && value <= static_cast(GSLimit24BitDepth::PrioritizeLower)) config.UserHacks_Limit24BitDepth = static_cast(value); } break; case GSHWFixId::AlignSprite: config.UserHacks_AlignSpriteX = (value > 0); break; case GSHWFixId::MergeSprite: config.UserHacks_MergePPSprite = (value > 0); break; case GSHWFixId::ForceEvenSpritePosition: config.UserHacks_ForceEvenSpritePosition = (value > 0); break; case GSHWFixId::BilinearUpscale: { if (value >= 0 && value < static_cast(GSBilinearDirtyMode::MaxCount)) config.UserHacks_BilinearHack = static_cast(value); } break; case GSHWFixId::NativePaletteDraw: config.UserHacks_NativePaletteDraw = (value > 0); break; case GSHWFixId::EstimateTextureRegion: config.UserHacks_EstimateTextureRegion = (value > 0); break; case GSHWFixId::DrawBuffering: config.UserHacks_DrawBuffering = (value > 0); break; case GSHWFixId::PCRTCOffsets: config.PCRTCOffsets = (value > 0); break; case GSHWFixId::PCRTCOverscan: config.PCRTCOverscan = (value > 0); break; case GSHWFixId::CoalesceRenderPasses: config.CoalesceRenderPasses = (value > 0); break; case GSHWFixId::Mipmap: config.HWMipmap = (value > 0); break; case GSHWFixId::AccurateAlphaTest: config.HWAccurateAlphaTest = (value > 0); break; case GSHWFixId::TrilinearFiltering: { if (value >= 0 && value <= static_cast(TriFiltering::Forced)) { if (config.TriFilter == TriFiltering::Automatic) config.TriFilter = static_cast(value); else if (config.TriFilter > TriFiltering::Off) Console.Warning("GameDB: Game requires trilinear filtering to be disabled."); } } break; case GSHWFixId::SkipDrawStart: config.SkipDrawStart = value; break; case GSHWFixId::SkipDrawEnd: config.SkipDrawEnd = value; break; case GSHWFixId::HalfPixelOffset: { if (value >= 0 && value < static_cast(GSHalfPixelOffset::MaxCount)) config.UserHacks_HalfPixelOffset = static_cast(value); } break; case GSHWFixId::RoundSprite: config.UserHacks_RoundSprite = value; break; case GSHWFixId::NativeScaling: config.UserHacks_NativeScaling = static_cast(value); break; case GSHWFixId::TexturePreloading: { if (value >= 0 && value <= static_cast(TexturePreloadingLevel::Full)) config.TexturePreloading = std::min(config.TexturePreloading, static_cast(value)); } break; case GSHWFixId::Deinterlace: { if (value >= static_cast(GSInterlaceMode::Automatic) && value < static_cast(GSInterlaceMode::Count)) { if (config.InterlaceMode == GSInterlaceMode::Automatic) config.InterlaceMode = static_cast(value); else Console.Warning("GameDB: Game requires different deinterlace mode but it has been overridden by user setting."); } } break; case GSHWFixId::CPUSpriteRenderBW: config.UserHacks_CPUSpriteRenderBW = value; break; case GSHWFixId::CPUSpriteRenderLevel: config.UserHacks_CPUSpriteRenderLevel = value; break; case GSHWFixId::CPUCLUTRender: config.UserHacks_CPUCLUTRender = value; break; case GSHWFixId::GPUTargetCLUT: { if (value >= 0 && value <= static_cast(GSGPUTargetCLUTMode::InsideTarget)) config.UserHacks_GPUTargetCLUTMode = static_cast(value); } break; case GSHWFixId::GPUPaletteConversion: { // if 2, enable paltex when preloading is full, otherwise leave as-is if (value > 1) config.GPUPaletteConversion = (config.TexturePreloading == TexturePreloadingLevel::Full) ? true : config.GPUPaletteConversion; else config.GPUPaletteConversion = (value != 0); } break; case GSHWFixId::MinimumBlendingLevel: { if (value >= 0 && value <= static_cast(AccBlendLevel::Maximum)) config.AccurateBlendingUnit = std::max(config.AccurateBlendingUnit, static_cast(value)); } break; case GSHWFixId::MaximumBlendingLevel: { if (value >= 0 && value <= static_cast(AccBlendLevel::Maximum)) config.AccurateBlendingUnit = std::min(config.AccurateBlendingUnit, static_cast(value)); } break; case GSHWFixId::RecommendedBlendingLevel: { if (!is_sw_renderer && value >= 0 && value <= static_cast(AccBlendLevel::Maximum) && static_cast(EmuConfig.GS.AccurateBlendingUnit) < value) { static constexpr std::array(AccBlendLevel::MaxCount)> s_blending_option_names = {{ TRANSLATE_NOOP("GameDatabase", "Minimum"), TRANSLATE_NOOP("GameDatabase", "Basic"), TRANSLATE_NOOP("GameDatabase", "Medium"), TRANSLATE_NOOP("GameDatabase", "High"), TRANSLATE_NOOP("GameDatabase", "Full"), TRANSLATE_NOOP("GameDatabase", "Maximum"), }}; Host::AddKeyedOSDMessage("HWBlendingWarning", fmt::format(TRANSLATE_FS("GameDatabase", "{0} Current Blending Accuracy is {1}.\n" "Recommended Blending Accuracy for this game is {2}.\n" "You can adjust the blending level in Game Properties to improve\n" "graphical quality, but this will increase system requirements."), ICON_FA_PAINTBRUSH, s_blending_option_names[static_cast(EmuConfig.GS.AccurateBlendingUnit)], s_blending_option_names[static_cast(value)]), Host::OSD_WARNING_DURATION); } else { Host::RemoveKeyedOSDMessage("HWBlendingWarning"); } } break; case GSHWFixId::RecommendedAccurateAlphaTest: { if (!is_sw_renderer && value >= 0 && value <= 1 && static_cast(config.HWAccurateAlphaTest) < value) { Host::AddKeyedOSDMessage("HWAlphaTestWarning", fmt::format(TRANSLATE_FS("GameDatabase", "{0} Accurate Alpha Test is currently disabled.\n" "This game recommends enabling Accurate Alpha Test.\n" "You can enable it in Game Properties to improve graphical\n" "accuracy, but this may increase system requirements."), ICON_FA_PAINTBRUSH), Host::OSD_WARNING_DURATION); } else { Host::RemoveKeyedOSDMessage("HWAlphaTestWarning"); } } break; case GSHWFixId::RecommendedHWAA1: { if (!is_sw_renderer && value >= 0 && value <= 1 && static_cast(config.HWAA1) < value) { Host::AddKeyedOSDMessage("HWAA1Warning", fmt::format(TRANSLATE_FS("GameDatabase", "{0} AA1 is currently disabled.\n" "This game recommends enabling AA1.\n" "You can enable it in Game Properties to improve graphical\n" "accuracy, but this may increase system requirements."), ICON_FA_PAINTBRUSH), Host::OSD_WARNING_DURATION); } else { Host::RemoveKeyedOSDMessage("HWAA1Warning"); } } break; case GSHWFixId::GetSkipCount: config.GetSkipCountFunctionId = static_cast(value); break; case GSHWFixId::BeforeDraw: config.BeforeDrawFunctionId = static_cast(value); break; case GSHWFixId::MoveHandler: config.MoveHandlerFunctionId = static_cast(value); break; default: break; } Console.WriteLn("GameDB: Enabled GS Hardware Fix: %s to [mode=%d]", getHWFixName(id), value); } // fixup skipdraw range just in case the db has a bad range (but the linter should catch this) config.SkipDrawEnd = std::max(config.SkipDrawStart, config.SkipDrawEnd); if (!is_sw_renderer && !disabled_fixes.empty()) { // Pinning a single hack lands here too, and then blaming manual mode would be a lie. const std::string_view reason = apply_auto_fixes ? TRANSLATE_SV("GameDatabase", "Your own choice was kept for these graphics fixes, so the automatic ones were not applied:") : TRANSLATE_SV("GameDatabase", "Manual GS hardware renderer fixes are enabled, automatic fixes were not applied:"); Host::AddKeyedOSDMessage("HWFixesWarning", fmt::format(ICON_FA_WAND_MAGIC_SPARKLES " {}\n{}", reason, disabled_fixes), Host::OSD_ERROR_DURATION); } else { Host::RemoveKeyedOSDMessage("HWFixesWarning"); } } void GameDatabase::loadFile(const std::string& path, const std::string& name, bool is_override) { const std::optional buffer = FileSystem::ReadFileToString(path.c_str()); if (!buffer.has_value()) { // Override file is optional — silent skip when not shipped. // Upstream is mandatory — keep the error loud. if (!is_override) Console.Error("GameDB: Unable to open GameDB file, file does not exist."); return; } const ryml::csubstr yaml = ryml::to_csubstr(*buffer); Error error; std::optional tree = ParseYAMLFromString(yaml, ryml::to_csubstr(name), &error, true); if (!tree.has_value()) { Console.ErrorFmt("GameDB: Failed to parse game database file {}:", path); Console.Error(error.GetDescription()); return; } ryml::NodeRef root = tree->rootref(); for (const ryml::NodeRef& n : root.children()) { auto serial = StringUtil::toLower(std::string(n.key().str, n.key().len)); // Serials and CRCs must be inserted as lower-case, as that is how they are retrieved // this is because the application may pass a lowercase CRC or serial along // // However, YAML's keys are as expected case-sensitive, so we have to explicitly do our own duplicate checking. // The override file legitimately reuses upstream serials by design — that's the whole point — so the duplicate // guard only fires in the upstream load path. if (!is_override && s_game_db.count(serial) == 1) { Console.ErrorFmt("GameDB: Duplicate serial '{}' found in GameDB. Skipping, Serials are case-insensitive!", serial); continue; } if (n.is_map()) { if (is_override) mergeOverride(serial, n); else parseAndInsert(serial, n); } } } void GameDatabase::initDatabase() { loadFile( Path::Combine(EmuFolders::Resources, GAMEDB_YAML_FILE_NAME), GAMEDB_YAML_FILE_NAME, /*is_override=*/false); loadFile( Path::Combine(EmuFolders::Resources, GAMEDB_OVERRIDE_YAML_FILE_NAME), GAMEDB_OVERRIDE_YAML_FILE_NAME, /*is_override=*/true); } void GameDatabase::ensureLoaded() { std::call_once(s_load_once_flag, []() { Common::Timer timer; Console.WriteLn(fmt::format("GameDB: Has not been initialized yet, initializing...")); initDatabase(); Console.WriteLn("GameDB: %zu games on record (loaded in %.2fms)", s_game_db.size(), timer.GetTimeMilliseconds()); }); } const GameDatabaseSchema::GameEntry* GameDatabase::findGame(const std::string_view serial) { GameDatabase::ensureLoaded(); auto iter = s_game_db.find(StringUtil::toLower(serial)); return (iter != s_game_db.end()) ? &iter->second : nullptr; } bool GameDatabase::TrackHash::parseHash(const std::string_view str) { constexpr u32 expected_length = SIZE * 2; if (str.length() != expected_length) return false; std::memset(data, 0, sizeof(data)); for (u32 i = 0; i < SIZE * 2; i++) { const char ch = str[i]; u8 b; if (ch >= '0' && ch <= '9') b = static_cast(ch - '0'); else if (ch >= 'a' && ch <= 'f') b = static_cast(ch - 'a') + 0xa; else if (ch >= 'A' && ch <= 'F') b = static_cast(ch - 'A') + 0xa; else return false; data[i / 2] |= ((i % 2) == 0) ? (b << 4) : b; } return true; } std::string GameDatabase::TrackHash::toString() const { return fmt::format( "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15]); } struct TrackHashHasher { size_t operator()(const GameDatabase::TrackHash& hash) const { return std::hash()(std::string_view(reinterpret_cast(hash.data), GameDatabase::TrackHash::SIZE)); } }; static constexpr char HASHDB_YAML_FILE_NAME[] = "RedumpDatabase.yaml"; std::unordered_map s_track_hash_to_entry_map; std::vector s_hash_database; static bool parseHashDatabaseEntry(const ryml::NodeRef& node) { if (!node.has_child("name") || !node.has_child("hashes")) { Console.Warning("[HashDatabase] Incomplete entry found."); return false; } GameDatabase::HashDatabaseEntry entry; node["name"] >> entry.name; if (node.has_child("version")) node["version"] >> entry.version; if (node.has_child("serial")) node["serial"] >> entry.serial; const u32 index = static_cast(s_hash_database.size()); for (const ryml::ConstNodeRef& n : node["hashes"].children()) { if (!n.is_map() || !n.has_child("size") || !n.has_child("md5")) { Console.ErrorFmt("[HashDatabase] Incomplete hash definition in {}", entry.name); return false; } GameDatabase::TrackHash th; std::string md5; n["md5"] >> md5; n["size"] >> th.size; if (!th.parseHash(md5)) { Console.ErrorFmt("[HashDatabase] Failed to parse hash in {}: '{}'", entry.name, md5); return false; } if (entry.tracks.empty() && s_track_hash_to_entry_map.find(th) != s_track_hash_to_entry_map.end()) Console.WarningFmt("[HashDatabase] Duplicate first track hash in {}", entry.name); entry.tracks.push_back(th); s_track_hash_to_entry_map.emplace(th, index); } s_hash_database.push_back(std::move(entry)); return true; } bool GameDatabase::loadHashDatabase() { if (!s_hash_database.empty()) return true; Common::Timer load_timer; const std::string path(Path::Combine(EmuFolders::Resources, HASHDB_YAML_FILE_NAME)); const std::string name(HASHDB_YAML_FILE_NAME); std::optional buffer = FileSystem::ReadFileToString(path.c_str()); if (!buffer.has_value()) { Console.Error("[HashDatabase] Unable to open hash database file, file does not exist."); return false; } ryml::csubstr yaml = ryml::to_csubstr(*buffer); Error error; std::optional tree = ParseYAMLFromString(yaml, ryml::to_csubstr(name), &error); if (!tree.has_value()) { Console.ErrorFmt("[HashDatabase] Failed to parse hash database file {}:", path); Console.Error(error.GetDescription()); return false; } ryml::NodeRef root = tree->rootref(); bool okay = true; for (const ryml::NodeRef& n : root.children()) { if (!parseHashDatabaseEntry(n)) { okay = false; break; } } if (!okay) { s_track_hash_to_entry_map.clear(); s_hash_database.clear(); return false; } Console.WriteLn(Color_StrongGreen, "[HashDatabase] Loaded YAML in %.0f ms", load_timer.GetTimeMilliseconds()); return true; } void GameDatabase::unloadHashDatabase() { s_track_hash_to_entry_map.clear(); s_hash_database.clear(); } static size_t getTrackIndex(const GameDatabase::TrackHash* tracks, size_t num_tracks, const GameDatabase::TrackHash& track) { for (size_t i = 0; i < num_tracks; i++) { if (tracks[i] == track) return i; } return num_tracks; } const GameDatabase::HashDatabaseEntry* GameDatabase::lookupHash( const TrackHash* tracks, size_t num_tracks, bool* tracks_matched, std::string* match_error) { loadHashDatabase(); if (num_tracks == 0) { *match_error = TRANSLATE_STR("GameDatabase", "No tracks provided."); std::memset(tracks_matched, 0, sizeof(bool) * num_tracks); return nullptr; } // match the first track, for DVDs this will be all there is anyway const auto data_iter = s_track_hash_to_entry_map.find(tracks[0]); if (data_iter == s_track_hash_to_entry_map.end()) { *match_error = fmt::format(TRANSLATE_FS("GameDatabase", "Hash {} is not in database."), tracks[0].toString()); std::memset(tracks_matched, 0, sizeof(bool) * num_tracks); return nullptr; } // make sure they're not missing the data track const GameDatabase::HashDatabaseEntry* candidate = &s_hash_database[data_iter->second]; if (getTrackIndex(candidate->tracks.data(), candidate->tracks.size(), tracks[0]) != 0) { *match_error = TRANSLATE_STR("GameDatabase", "Data track number does not match data track in database."); std::memset(tracks_matched, 0, sizeof(bool) * num_tracks); return nullptr; } // first track is okay! tracks_matched[0] = true; match_error->clear(); // now check any audio tracks... bool all_okay = true; for (size_t track = 1; track < num_tracks; track++) { const auto audio_iter = s_track_hash_to_entry_map.find(tracks[track]); if (audio_iter != s_track_hash_to_entry_map.end()) { fmt::format_to(std::back_inserter(*match_error), TRANSLATE_FS("GameDatabase", "Track {0} with hash {1} is not found in database.\n"), track + 1, tracks[track].toString()); tracks_matched[track] = false; all_okay = false; continue; } // same game? if (audio_iter->second != data_iter->second) { fmt::format_to(std::back_inserter(*match_error), TRANSLATE_FS("GameDatabase", "Track {0} with hash {1} is for a different game ({2}).\n"), track + 1, tracks[track].toString(), s_hash_database[audio_iter->second].name); tracks_matched[track] = false; all_okay = false; continue; } // make sure it's the correct track number if (getTrackIndex(candidate->tracks.data(), candidate->tracks.size(), tracks[track]) != track) { fmt::format_to(std::back_inserter(*match_error), TRANSLATE_FS("GameDatabase", "Track {0} with hash {1} does not match database track.\n"), track + 1, tracks[track].toString()); tracks_matched[track] = false; all_okay = false; continue; } tracks_matched[track] = true; } if (!match_error->empty() && match_error->back() == '\n') match_error->pop_back(); return all_okay ? candidate : nullptr; }