duckstation

duckstation, but archived from the revision just before upstream changed it to a proprietary software project, this version is the libre one
git clone https://git.neptards.moe/u3shit/duckstation.git
Log | Files | Refs | README | LICENSE

game_list.cpp (59055B)


      1 // SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
      2 // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0)
      3 
      4 #include "game_list.h"
      5 #include "bios.h"
      6 #include "fullscreen_ui.h"
      7 #include "host.h"
      8 #include "memory_card_image.h"
      9 #include "psf_loader.h"
     10 #include "settings.h"
     11 #include "system.h"
     12 
     13 #include "util/cd_image.h"
     14 #include "util/http_downloader.h"
     15 #include "util/image.h"
     16 #include "util/ini_settings_interface.h"
     17 
     18 #include "common/binary_reader_writer.h"
     19 #include "common/error.h"
     20 #include "common/file_system.h"
     21 #include "common/heterogeneous_containers.h"
     22 #include "common/log.h"
     23 #include "common/path.h"
     24 #include "common/progress_callback.h"
     25 #include "common/string_util.h"
     26 #include "common/timer.h"
     27 
     28 #include <algorithm>
     29 #include <array>
     30 #include <ctime>
     31 #include <string_view>
     32 #include <type_traits>
     33 #include <unordered_map>
     34 #include <utility>
     35 
     36 Log_SetChannel(GameList);
     37 
     38 #ifdef _WIN32
     39 #include "common/windows_headers.h"
     40 #endif
     41 
     42 namespace GameList {
     43 namespace {
     44 
     45 enum : u32
     46 {
     47   GAME_LIST_CACHE_SIGNATURE = 0x45434C48,
     48   GAME_LIST_CACHE_VERSION = 35,
     49 
     50   PLAYED_TIME_SERIAL_LENGTH = 32,
     51   PLAYED_TIME_LAST_TIME_LENGTH = 20,  // uint64
     52   PLAYED_TIME_TOTAL_TIME_LENGTH = 20, // uint64
     53   PLAYED_TIME_LINE_LENGTH =
     54     PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1 + PLAYED_TIME_TOTAL_TIME_LENGTH,
     55 };
     56 
     57 struct PlayedTimeEntry
     58 {
     59   std::time_t last_played_time;
     60   std::time_t total_played_time;
     61 };
     62 
     63 #pragma pack(push, 1)
     64 struct MemcardTimestampCacheEntry
     65 {
     66   enum : u32
     67   {
     68     MAX_SERIAL_LENGTH = 31,
     69   };
     70 
     71   char serial[MAX_SERIAL_LENGTH];
     72   bool icon_was_extracted;
     73   s64 memcard_timestamp;
     74 };
     75 #pragma pack(pop)
     76 
     77 } // namespace
     78 
     79 using CacheMap = PreferUnorderedStringMap<Entry>;
     80 using PlayedTimeMap = PreferUnorderedStringMap<PlayedTimeEntry>;
     81 
     82 static_assert(std::is_same_v<decltype(Entry::hash), System::GameHash>);
     83 
     84 static bool GetExeListEntry(const std::string& path, Entry* entry);
     85 static bool GetPsfListEntry(const std::string& path, Entry* entry);
     86 static bool GetDiscListEntry(const std::string& path, Entry* entry);
     87 
     88 static void ApplyCustomAttributes(const std::string& path, Entry* entry,
     89                                   const INISettingsInterface& custom_attributes_ini);
     90 static bool RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini);
     91 static bool GetGameListEntryFromCache(const std::string& path, Entry* entry,
     92                                       const INISettingsInterface& custom_attributes_ini);
     93 static Entry* GetMutableEntryForPath(std::string_view path);
     94 static void ScanDirectory(const char* path, bool recursive, bool only_cache,
     95                           const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map,
     96                           const INISettingsInterface& custom_attributes_ini, BinaryFileWriter& cache_writer,
     97                           ProgressCallback* progress);
     98 static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map,
     99                              const INISettingsInterface& custom_attributes_ini);
    100 static bool ScanFile(std::string path, std::time_t timestamp, std::unique_lock<std::recursive_mutex>& lock,
    101                      const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini,
    102                      BinaryFileWriter& cache_writer);
    103 
    104 static bool LoadOrInitializeCache(std::FILE* fp, bool invalidate_cache);
    105 static bool LoadEntriesFromCache(BinaryFileReader& reader);
    106 static bool WriteEntryToCache(const Entry* entry, BinaryFileWriter& writer);
    107 static void CreateDiscSetEntries(const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map);
    108 
    109 static std::string GetPlayedTimeFile();
    110 static bool ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry);
    111 static std::string MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry);
    112 static PlayedTimeMap LoadPlayedTimeMap(const std::string& path);
    113 static PlayedTimeEntry UpdatePlayedTimeFile(const std::string& path, const std::string& serial, std::time_t last_time,
    114                                             std::time_t add_time);
    115 
    116 static std::string GetCustomPropertiesFile();
    117 
    118 static FileSystem::ManagedCFilePtr OpenMemoryCardTimestampCache(bool for_write);
    119 static bool UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry);
    120 
    121 static EntryList s_entries;
    122 static std::recursive_mutex s_mutex;
    123 static CacheMap s_cache_map;
    124 static std::vector<MemcardTimestampCacheEntry> s_memcard_timestamp_cache_entries;
    125 
    126 static bool s_game_list_loaded = false;
    127 
    128 } // namespace GameList
    129 
    130 const char* GameList::GetEntryTypeName(EntryType type)
    131 {
    132   static std::array<const char*, static_cast<int>(EntryType::Count)> names = {
    133     {"Disc", "DiscSet", "PSExe", "Playlist", "PSF"}};
    134   return names[static_cast<int>(type)];
    135 }
    136 
    137 const char* GameList::GetEntryTypeDisplayName(EntryType type)
    138 {
    139   static std::array<const char*, static_cast<int>(EntryType::Count)> names = {
    140     {TRANSLATE_NOOP("GameList", "Disc"), TRANSLATE_NOOP("GameList", "Disc Set"), TRANSLATE_NOOP("GameList", "PS-EXE"),
    141      TRANSLATE_NOOP("GameList", "Playlist"), TRANSLATE_NOOP("GameList", "PSF")}};
    142   return Host::TranslateToCString("GameList", names[static_cast<int>(type)]);
    143 }
    144 
    145 bool GameList::IsGameListLoaded()
    146 {
    147   return s_game_list_loaded;
    148 }
    149 
    150 bool GameList::IsScannableFilename(std::string_view path)
    151 {
    152   // we don't scan bin files because they'll duplicate
    153   if (StringUtil::EndsWithNoCase(path, ".bin"))
    154     return false;
    155 
    156   return System::IsLoadableFilename(path);
    157 }
    158 
    159 bool GameList::GetExeListEntry(const std::string& path, GameList::Entry* entry)
    160 {
    161   std::FILE* fp = FileSystem::OpenCFile(path.c_str(), "rb");
    162   if (!fp)
    163     return false;
    164 
    165   std::fseek(fp, 0, SEEK_END);
    166   const u32 file_size = static_cast<u32>(std::ftell(fp));
    167   std::fseek(fp, 0, SEEK_SET);
    168 
    169   BIOS::PSEXEHeader header;
    170   if (std::fread(&header, sizeof(header), 1, fp) != 1)
    171   {
    172     std::fclose(fp);
    173     return false;
    174   }
    175 
    176   std::fclose(fp);
    177 
    178   if (!BIOS::IsValidPSExeHeader(header, file_size))
    179   {
    180     WARNING_LOG("{} is not a valid PS-EXE", path);
    181     return false;
    182   }
    183 
    184   const System::GameHash hash = System::GetGameHashFromFile(path.c_str());
    185 
    186   entry->serial = hash ? System::GetGameHashId(hash) : std::string();
    187   entry->title = Path::GetFileTitle(FileSystem::GetDisplayNameFromPath(path));
    188   entry->region = BIOS::GetPSExeDiscRegion(header);
    189   entry->file_size = ZeroExtend64(file_size);
    190   entry->uncompressed_size = entry->file_size;
    191   entry->type = EntryType::PSExe;
    192   entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
    193 
    194   return true;
    195 }
    196 
    197 bool GameList::GetPsfListEntry(const std::string& path, Entry* entry)
    198 {
    199   // we don't need to walk the library chain here - the top file is enough
    200   PSFLoader::File file;
    201   if (!file.Load(path.c_str(), nullptr))
    202     return false;
    203 
    204   entry->serial.clear();
    205   entry->region = file.GetRegion();
    206   entry->file_size = static_cast<u32>(file.GetProgramData().size());
    207   entry->uncompressed_size = entry->file_size;
    208   entry->type = EntryType::PSF;
    209   entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
    210 
    211   // Game - Title
    212   std::optional<std::string> game(file.GetTagString("game"));
    213   if (game.has_value())
    214   {
    215     entry->title = std::move(game.value());
    216     entry->title += " - ";
    217   }
    218   else
    219   {
    220     entry->title.clear();
    221   }
    222 
    223   std::optional<std::string> title(file.GetTagString("title"));
    224   if (title.has_value())
    225   {
    226     entry->title += title.value();
    227   }
    228   else
    229   {
    230     const std::string display_name(FileSystem::GetDisplayNameFromPath(path));
    231     entry->title += Path::GetFileTitle(display_name);
    232   }
    233 
    234   return true;
    235 }
    236 
    237 bool GameList::GetDiscListEntry(const std::string& path, Entry* entry)
    238 {
    239   std::unique_ptr<CDImage> cdi = CDImage::Open(path.c_str(), false, nullptr);
    240   if (!cdi)
    241     return false;
    242 
    243   entry->path = path;
    244   entry->file_size = cdi->GetSizeOnDisk();
    245   entry->uncompressed_size = static_cast<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(cdi->GetLBACount());
    246   entry->type = EntryType::Disc;
    247   entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
    248 
    249   std::string id;
    250   System::GetGameDetailsFromImage(cdi.get(), &id, &entry->hash);
    251 
    252   // try the database first
    253   const GameDatabase::Entry* dentry = GameDatabase::GetEntryForGameDetails(id, entry->hash);
    254   if (dentry)
    255   {
    256     // pull from database
    257     entry->serial = dentry->serial;
    258     entry->title = dentry->title;
    259     entry->genre = dentry->genre;
    260     entry->publisher = dentry->publisher;
    261     entry->developer = dentry->developer;
    262     entry->release_date = dentry->release_date;
    263     entry->min_players = dentry->min_players;
    264     entry->max_players = dentry->max_players;
    265     entry->min_blocks = dentry->min_blocks;
    266     entry->max_blocks = dentry->max_blocks;
    267     entry->supported_controllers = dentry->supported_controllers;
    268     entry->compatibility = dentry->compatibility;
    269 
    270     if (!cdi->HasSubImages())
    271     {
    272       for (size_t i = 0; i < dentry->disc_set_serials.size(); i++)
    273       {
    274         if (dentry->disc_set_serials[i] == entry->serial)
    275         {
    276           entry->disc_set_name = dentry->disc_set_name;
    277           entry->disc_set_index = static_cast<s8>(i);
    278           break;
    279         }
    280       }
    281     }
    282   }
    283   else
    284   {
    285     const std::string display_name(FileSystem::GetDisplayNameFromPath(path));
    286 
    287     // no game code, so use the filename title
    288     entry->serial = std::move(id);
    289     entry->title = Path::GetFileTitle(display_name);
    290     entry->compatibility = GameDatabase::CompatibilityRating::Unknown;
    291     entry->release_date = 0;
    292     entry->min_players = 0;
    293     entry->max_players = 0;
    294     entry->min_blocks = 0;
    295     entry->max_blocks = 0;
    296     entry->supported_controllers = static_cast<u16>(~0u);
    297   }
    298 
    299   // region detection
    300   entry->region = System::GetRegionForImage(cdi.get());
    301 
    302   if (cdi->HasSubImages())
    303   {
    304     entry->type = EntryType::Playlist;
    305 
    306     std::string image_title(cdi->GetMetadata("title"));
    307     if (!image_title.empty())
    308       entry->title = std::move(image_title);
    309 
    310     // get the size of all the subimages
    311     const u32 subimage_count = cdi->GetSubImageCount();
    312     for (u32 i = 1; i < subimage_count; i++)
    313     {
    314       if (!cdi->SwitchSubImage(i, nullptr))
    315       {
    316         ERROR_LOG("Failed to switch to subimage {} in '{}'", i, entry->path);
    317         continue;
    318       }
    319 
    320       entry->uncompressed_size += static_cast<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(cdi->GetLBACount());
    321     }
    322   }
    323 
    324   return true;
    325 }
    326 
    327 bool GameList::PopulateEntryFromPath(const std::string& path, Entry* entry)
    328 {
    329   if (System::IsExeFileName(path))
    330     return GetExeListEntry(path, entry);
    331   if (System::IsPsfFileName(path.c_str()))
    332     return GetPsfListEntry(path, entry);
    333   return GetDiscListEntry(path, entry);
    334 }
    335 
    336 bool GameList::GetGameListEntryFromCache(const std::string& path, Entry* entry,
    337                                          const INISettingsInterface& custom_attributes_ini)
    338 {
    339   auto iter = s_cache_map.find(path);
    340   if (iter == s_cache_map.end())
    341     return false;
    342 
    343   *entry = std::move(iter->second);
    344   s_cache_map.erase(iter);
    345   ApplyCustomAttributes(path, entry, custom_attributes_ini);
    346   return true;
    347 }
    348 
    349 bool GameList::LoadEntriesFromCache(BinaryFileReader& reader)
    350 {
    351   u32 file_signature, file_version;
    352   if (!reader.ReadU32(&file_signature) || !reader.ReadU32(&file_version) ||
    353       file_signature != GAME_LIST_CACHE_SIGNATURE || file_version != GAME_LIST_CACHE_VERSION)
    354   {
    355     WARNING_LOG("Game list cache is corrupted");
    356     return false;
    357   }
    358 
    359   while (!reader.IsAtEnd())
    360   {
    361     std::string path;
    362     Entry ge;
    363 
    364     u8 type;
    365     u8 region;
    366     u8 compatibility_rating;
    367 
    368     if (!reader.ReadU8(&type) || !reader.ReadU8(&region) || !reader.ReadSizePrefixedString(&path) ||
    369         !reader.ReadSizePrefixedString(&ge.serial) || !reader.ReadSizePrefixedString(&ge.title) ||
    370         !reader.ReadSizePrefixedString(&ge.disc_set_name) || !reader.ReadSizePrefixedString(&ge.genre) ||
    371         !reader.ReadSizePrefixedString(&ge.publisher) || !reader.ReadSizePrefixedString(&ge.developer) ||
    372         !reader.ReadU64(&ge.hash) || !reader.ReadS64(&ge.file_size) || !reader.ReadU64(&ge.uncompressed_size) ||
    373         !reader.ReadU64(reinterpret_cast<u64*>(&ge.last_modified_time)) || !reader.ReadU64(&ge.release_date) ||
    374         !reader.ReadU16(&ge.supported_controllers) || !reader.ReadU8(&ge.min_players) ||
    375         !reader.ReadU8(&ge.max_players) || !reader.ReadU8(&ge.min_blocks) || !reader.ReadU8(&ge.max_blocks) ||
    376         !reader.ReadS8(&ge.disc_set_index) || !reader.ReadU8(&compatibility_rating) ||
    377         region >= static_cast<u8>(DiscRegion::Count) || type >= static_cast<u8>(EntryType::Count) ||
    378         compatibility_rating >= static_cast<u8>(GameDatabase::CompatibilityRating::Count))
    379     {
    380       WARNING_LOG("Game list cache entry is corrupted");
    381       return false;
    382     }
    383 
    384     ge.path = path;
    385     ge.region = static_cast<DiscRegion>(region);
    386     ge.type = static_cast<EntryType>(type);
    387     ge.compatibility = static_cast<GameDatabase::CompatibilityRating>(compatibility_rating);
    388 
    389     auto iter = s_cache_map.find(ge.path);
    390     if (iter != s_cache_map.end())
    391       iter->second = std::move(ge);
    392     else
    393       s_cache_map.emplace(std::move(path), std::move(ge));
    394   }
    395 
    396   return true;
    397 }
    398 
    399 bool GameList::WriteEntryToCache(const Entry* entry, BinaryFileWriter& writer)
    400 {
    401   writer.WriteU8(static_cast<u8>(entry->type));
    402   writer.WriteU8(static_cast<u8>(entry->region));
    403   writer.WriteSizePrefixedString(entry->path);
    404   writer.WriteSizePrefixedString(entry->serial);
    405   writer.WriteSizePrefixedString(entry->title);
    406   writer.WriteSizePrefixedString(entry->disc_set_name);
    407   writer.WriteSizePrefixedString(entry->genre);
    408   writer.WriteSizePrefixedString(entry->publisher);
    409   writer.WriteSizePrefixedString(entry->developer);
    410   writer.WriteU64(entry->hash);
    411   writer.WriteS64(entry->file_size);
    412   writer.WriteU64(entry->uncompressed_size);
    413   writer.WriteU64(entry->last_modified_time);
    414   writer.WriteU64(entry->release_date);
    415   writer.WriteU16(entry->supported_controllers);
    416   writer.WriteU8(entry->min_players);
    417   writer.WriteU8(entry->max_players);
    418   writer.WriteU8(entry->min_blocks);
    419   writer.WriteU8(entry->max_blocks);
    420   writer.WriteS8(entry->disc_set_index);
    421   writer.WriteU8(static_cast<u8>(entry->compatibility));
    422   return writer.IsGood();
    423 }
    424 
    425 bool GameList::LoadOrInitializeCache(std::FILE* fp, bool invalidate_cache)
    426 {
    427   BinaryFileReader reader(fp);
    428   if (!invalidate_cache && !reader.IsAtEnd() && LoadEntriesFromCache(reader))
    429   {
    430     // Prepare for writing.
    431     return (FileSystem::FSeek64(fp, 0, SEEK_END) == 0);
    432   }
    433 
    434   WARNING_LOG("Initializing game list cache.");
    435   s_cache_map.clear();
    436 
    437   // Truncate file, and re-write header.
    438   Error error;
    439   if (!FileSystem::FSeek64(fp, 0, SEEK_SET, &error) || !FileSystem::FTruncate64(fp, 0, &error))
    440   {
    441     ERROR_LOG("Failed to truncate game list cache: {}", error.GetDescription());
    442     return false;
    443   }
    444 
    445   BinaryFileWriter writer(fp);
    446   writer.WriteU32(GAME_LIST_CACHE_SIGNATURE);
    447   writer.WriteU32((GAME_LIST_CACHE_VERSION));
    448   if (!writer.Flush(&error))
    449   {
    450     ERROR_LOG("Failed to write game list cache header: {}", error.GetDescription());
    451     return false;
    452   }
    453 
    454   return true;
    455 }
    456 
    457 static bool IsPathExcluded(const std::vector<std::string>& excluded_paths, const std::string& path)
    458 {
    459   return std::find_if(excluded_paths.begin(), excluded_paths.end(),
    460                       [&path](const std::string& entry) { return path.starts_with(entry); }) != excluded_paths.end();
    461 }
    462 
    463 void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache,
    464                              const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map,
    465                              const INISettingsInterface& custom_attributes_ini, BinaryFileWriter& cache_writer,
    466                              ProgressCallback* progress)
    467 {
    468   INFO_LOG("Scanning {}{}", path, recursive ? " (recursively)" : "");
    469 
    470   progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning directory '{}'..."), path));
    471 
    472   FileSystem::FindResultsArray files;
    473   FileSystem::FindFiles(path, "*",
    474                         recursive ? (FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES | FILESYSTEM_FIND_RECURSIVE) :
    475                                     (FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES),
    476                         &files);
    477   if (files.empty())
    478     return;
    479 
    480   progress->PushState();
    481   progress->SetProgressRange(static_cast<u32>(files.size()));
    482   progress->SetProgressValue(0);
    483 
    484   u32 files_scanned = 0;
    485   for (FILESYSTEM_FIND_DATA& ffd : files)
    486   {
    487     files_scanned++;
    488 
    489     if (progress->IsCancelled() || !GameList::IsScannableFilename(ffd.FileName) ||
    490         IsPathExcluded(excluded_paths, ffd.FileName))
    491     {
    492       continue;
    493     }
    494 
    495     std::unique_lock lock(s_mutex);
    496     if (GetEntryForPath(ffd.FileName) ||
    497         AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map, custom_attributes_ini) || only_cache)
    498     {
    499       continue;
    500     }
    501 
    502     progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning '{}'..."),
    503                                                      FileSystem::GetDisplayNameFromPath(ffd.FileName)));
    504     ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map, custom_attributes_ini, cache_writer);
    505     progress->SetProgressValue(files_scanned);
    506   }
    507 
    508   progress->SetProgressValue(files_scanned);
    509   progress->PopState();
    510 }
    511 
    512 bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map,
    513                                 const INISettingsInterface& custom_attributes_ini)
    514 {
    515   Entry entry;
    516   if (!GetGameListEntryFromCache(path, &entry, custom_attributes_ini) || entry.last_modified_time != timestamp)
    517     return false;
    518 
    519   auto iter = played_time_map.find(entry.serial);
    520   if (iter != played_time_map.end())
    521   {
    522     entry.last_played_time = iter->second.last_played_time;
    523     entry.total_played_time = iter->second.total_played_time;
    524   }
    525 
    526   s_entries.push_back(std::move(entry));
    527   return true;
    528 }
    529 
    530 bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_lock<std::recursive_mutex>& lock,
    531                         const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini,
    532                         BinaryFileWriter& cache_writer)
    533 {
    534   // don't block UI while scanning
    535   lock.unlock();
    536 
    537   DEV_LOG("Scanning '{}'...", path);
    538 
    539   Entry entry;
    540   if (!PopulateEntryFromPath(path, &entry))
    541     return false;
    542 
    543   entry.path = std::move(path);
    544   entry.last_modified_time = timestamp;
    545 
    546   if (cache_writer.IsOpen() && !WriteEntryToCache(&entry, cache_writer)) [[unlikely]]
    547     WARNING_LOG("Failed to write entry '{}' to cache", entry.path);
    548 
    549   const auto iter = played_time_map.find(entry.serial);
    550   if (iter != played_time_map.end())
    551   {
    552     entry.last_played_time = iter->second.last_played_time;
    553     entry.total_played_time = iter->second.total_played_time;
    554   }
    555 
    556   ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini);
    557 
    558   lock.lock();
    559 
    560   // replace if present
    561   auto it = std::find_if(s_entries.begin(), s_entries.end(),
    562                          [&entry](const Entry& existing_entry) { return (existing_entry.path == entry.path); });
    563   if (it != s_entries.end())
    564     *it = std::move(entry);
    565   else
    566     s_entries.push_back(std::move(entry));
    567 
    568   return true;
    569 }
    570 
    571 bool GameList::RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini)
    572 {
    573   FILESYSTEM_STAT_DATA sd;
    574   if (!FileSystem::StatFile(path.c_str(), &sd))
    575     return false;
    576 
    577   {
    578     // cancel if excluded
    579     const std::vector<std::string> excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths"));
    580     if (IsPathExcluded(excluded_paths, path))
    581       return false;
    582   }
    583 
    584   Entry entry;
    585   if (!PopulateEntryFromPath(path, &entry))
    586     return false;
    587 
    588   entry.path = path;
    589   entry.last_modified_time = sd.ModificationTime;
    590 
    591   const PlayedTimeMap played_time_map(LoadPlayedTimeMap(GetPlayedTimeFile()));
    592   const auto iter = played_time_map.find(entry.serial);
    593   if (iter != played_time_map.end())
    594   {
    595     entry.last_played_time = iter->second.last_played_time;
    596     entry.total_played_time = iter->second.total_played_time;
    597   }
    598 
    599   ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini);
    600 
    601   std::unique_lock lock(s_mutex);
    602 
    603   // replace if present
    604   auto it = std::find_if(s_entries.begin(), s_entries.end(),
    605                          [&entry](const Entry& existing_entry) { return (existing_entry.path == entry.path); });
    606   if (it != s_entries.end())
    607     *it = std::move(entry);
    608   else
    609     s_entries.push_back(std::move(entry));
    610 
    611   return true;
    612 }
    613 
    614 void GameList::ApplyCustomAttributes(const std::string& path, Entry* entry,
    615                                      const INISettingsInterface& custom_attributes_ini)
    616 {
    617   std::optional<std::string> custom_title = custom_attributes_ini.GetOptionalStringValue(path.c_str(), "Title");
    618   if (custom_title.has_value())
    619   {
    620     entry->title = std::move(custom_title.value());
    621     entry->has_custom_title = true;
    622   }
    623   const std::optional<SmallString> custom_region_str =
    624     custom_attributes_ini.GetOptionalSmallStringValue(path.c_str(), "Region");
    625   if (custom_region_str.has_value())
    626   {
    627     const std::optional<DiscRegion> custom_region = Settings::ParseDiscRegionName(custom_region_str.value());
    628     if (custom_region.has_value())
    629     {
    630       entry->region = custom_region.value();
    631       entry->has_custom_region = true;
    632     }
    633     else
    634     {
    635       WARNING_LOG("Invalid region '{}' in custom attributes for '{}'", custom_region_str.value(), path);
    636     }
    637   }
    638 }
    639 
    640 std::unique_lock<std::recursive_mutex> GameList::GetLock()
    641 {
    642   return std::unique_lock<std::recursive_mutex>(s_mutex);
    643 }
    644 
    645 const GameList::Entry* GameList::GetEntryByIndex(u32 index)
    646 {
    647   return (index < s_entries.size()) ? &s_entries[index] : nullptr;
    648 }
    649 
    650 const GameList::Entry* GameList::GetEntryForPath(std::string_view path)
    651 {
    652   return GetMutableEntryForPath(path);
    653 }
    654 
    655 GameList::Entry* GameList::GetMutableEntryForPath(std::string_view path)
    656 {
    657   for (Entry& entry : s_entries)
    658   {
    659     // Use case-insensitive compare on Windows, since it's the same file.
    660 #ifdef _WIN32
    661     if (StringUtil::EqualNoCase(entry.path, path))
    662       return &entry;
    663 #else
    664     if (entry.path == path)
    665       return &entry;
    666 #endif
    667   }
    668 
    669   return nullptr;
    670 }
    671 
    672 const GameList::Entry* GameList::GetEntryBySerial(std::string_view serial)
    673 {
    674   for (const Entry& entry : s_entries)
    675   {
    676     if (entry.serial == serial)
    677       return &entry;
    678   }
    679 
    680   return nullptr;
    681 }
    682 
    683 const GameList::Entry* GameList::GetEntryBySerialAndHash(std::string_view serial, u64 hash)
    684 {
    685   for (const Entry& entry : s_entries)
    686   {
    687     if (entry.serial == serial && entry.hash == hash)
    688       return &entry;
    689   }
    690 
    691   return nullptr;
    692 }
    693 
    694 std::vector<const GameList::Entry*> GameList::GetDiscSetMembers(std::string_view disc_set_name,
    695                                                                 bool sort_by_most_recent)
    696 {
    697   std::vector<const Entry*> ret;
    698   for (const Entry& entry : s_entries)
    699   {
    700     if (!entry.disc_set_member || disc_set_name != entry.disc_set_name)
    701       continue;
    702 
    703     ret.push_back(&entry);
    704   }
    705 
    706   if (sort_by_most_recent)
    707   {
    708     std::sort(ret.begin(), ret.end(), [](const Entry* lhs, const Entry* rhs) {
    709       if (lhs->last_played_time == rhs->last_played_time)
    710         return (lhs->disc_set_index < rhs->disc_set_index);
    711       else
    712         return (lhs->last_played_time > rhs->last_played_time);
    713     });
    714   }
    715   else
    716   {
    717     std::sort(ret.begin(), ret.end(),
    718               [](const Entry* lhs, const Entry* rhs) { return (lhs->disc_set_index < rhs->disc_set_index); });
    719   }
    720 
    721   return ret;
    722 }
    723 
    724 const GameList::Entry* GameList::GetFirstDiscSetMember(std::string_view disc_set_name)
    725 {
    726   for (const Entry& entry : s_entries)
    727   {
    728     if (!entry.disc_set_member || disc_set_name != entry.disc_set_name)
    729       continue;
    730 
    731     // Disc set should not have been created without the first disc being present.
    732     if (entry.disc_set_index == 0)
    733       return &entry;
    734   }
    735 
    736   return nullptr;
    737 }
    738 
    739 u32 GameList::GetEntryCount()
    740 {
    741   return static_cast<u32>(s_entries.size());
    742 }
    743 
    744 void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* progress /* = nullptr */)
    745 {
    746   s_game_list_loaded = true;
    747 
    748   if (!progress)
    749     progress = ProgressCallback::NullProgressCallback;
    750 
    751   Error error;
    752   FileSystem::ManagedCFilePtr cache_file =
    753     FileSystem::OpenExistingOrCreateManagedCFile(Path::Combine(EmuFolders::Cache, "gamelist.cache").c_str(), 0, &error);
    754   if (!cache_file)
    755     ERROR_LOG("Failed to open game list cache: {}", error.GetDescription());
    756 
    757 #ifndef _WIN32
    758   // Lock cache file for multi-instance on Linux. Implicitly done on Windows.
    759   std::optional<FileSystem::POSIXLock> cache_file_lock;
    760   if (cache_file)
    761     cache_file_lock.emplace(cache_file.get());
    762   if (!LoadOrInitializeCache(cache_file.get(), invalidate_cache))
    763   {
    764     cache_file_lock.reset();
    765     cache_file.reset();
    766   }
    767 #else
    768   if (!LoadOrInitializeCache(cache_file.get(), invalidate_cache))
    769     cache_file.reset();
    770 #endif
    771   BinaryFileWriter cache_writer(cache_file.get());
    772 
    773   // don't delete the old entries, since the frontend might still access them
    774   std::vector<Entry> old_entries;
    775   {
    776     std::unique_lock lock(s_mutex);
    777     old_entries.swap(s_entries);
    778   }
    779 
    780   const std::vector<std::string> excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths"));
    781   const std::vector<std::string> dirs(Host::GetBaseStringListSetting("GameList", "Paths"));
    782   std::vector<std::string> recursive_dirs(Host::GetBaseStringListSetting("GameList", "RecursivePaths"));
    783   const PlayedTimeMap played_time(LoadPlayedTimeMap(GetPlayedTimeFile()));
    784   INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
    785   custom_attributes_ini.Load();
    786 
    787 #ifdef __ANDROID__
    788   recursive_dirs.push_back(Path::Combine(EmuFolders::DataRoot, "games"));
    789 #endif
    790 
    791   if (!dirs.empty() || !recursive_dirs.empty())
    792   {
    793     progress->SetProgressRange(static_cast<u32>(dirs.size() + recursive_dirs.size()));
    794     progress->SetProgressValue(0);
    795 
    796     // we manually count it here, because otherwise pop state updates it itself
    797     int directory_counter = 0;
    798     for (const std::string& dir : dirs)
    799     {
    800       if (progress->IsCancelled())
    801         break;
    802 
    803       ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, custom_attributes_ini, cache_writer,
    804                     progress);
    805       progress->SetProgressValue(++directory_counter);
    806     }
    807     for (const std::string& dir : recursive_dirs)
    808     {
    809       if (progress->IsCancelled())
    810         break;
    811 
    812       ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, custom_attributes_ini, cache_writer,
    813                     progress);
    814       progress->SetProgressValue(++directory_counter);
    815     }
    816   }
    817 
    818   // don't need unused cache entries
    819   s_cache_map.clear();
    820 
    821   // merge multi-disc games
    822   CreateDiscSetEntries(excluded_paths, played_time);
    823 }
    824 
    825 GameList::EntryList GameList::TakeEntryList()
    826 {
    827   EntryList ret = std::move(s_entries);
    828   s_entries = {};
    829   return ret;
    830 }
    831 
    832 void GameList::CreateDiscSetEntries(const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map)
    833 {
    834   std::unique_lock lock(s_mutex);
    835 
    836   for (size_t i = 0; i < s_entries.size(); i++)
    837   {
    838     const Entry& entry = s_entries[i];
    839 
    840     // only first discs can create sets
    841     if (entry.type != EntryType::Disc || entry.disc_set_member || entry.disc_set_index != 0)
    842       continue;
    843 
    844     // already have a disc set by this name?
    845     const std::string& disc_set_name = entry.disc_set_name;
    846     if (GetEntryForPath(disc_set_name.c_str()))
    847       continue;
    848 
    849     const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(entry.serial);
    850     if (!dbentry)
    851       continue;
    852 
    853     // need at least two discs for a set
    854     bool found_another_disc = false;
    855     for (const Entry& other_entry : s_entries)
    856     {
    857       if (other_entry.type != EntryType::Disc || other_entry.disc_set_member ||
    858           other_entry.disc_set_name != disc_set_name || other_entry.disc_set_index == entry.disc_set_index)
    859       {
    860         continue;
    861       }
    862       found_another_disc = true;
    863       break;
    864     }
    865     if (!found_another_disc)
    866     {
    867       DEV_LOG("Not creating disc set {}, only one disc found", disc_set_name);
    868       continue;
    869     }
    870 
    871     Entry set_entry;
    872     set_entry.type = EntryType::DiscSet;
    873     set_entry.region = entry.region;
    874     set_entry.path = disc_set_name;
    875     set_entry.serial = entry.serial;
    876     set_entry.title = entry.disc_set_name;
    877     set_entry.genre = entry.developer;
    878     set_entry.publisher = entry.publisher;
    879     set_entry.developer = entry.developer;
    880     set_entry.hash = entry.hash;
    881     set_entry.file_size = 0;
    882     set_entry.uncompressed_size = 0;
    883     set_entry.last_modified_time = entry.last_modified_time;
    884     set_entry.last_played_time = 0;
    885     set_entry.total_played_time = 0;
    886     set_entry.release_date = entry.release_date;
    887     set_entry.supported_controllers = entry.supported_controllers;
    888     set_entry.min_players = entry.min_players;
    889     set_entry.max_players = entry.max_players;
    890     set_entry.min_blocks = entry.min_blocks;
    891     set_entry.max_blocks = entry.max_blocks;
    892     set_entry.compatibility = entry.compatibility;
    893 
    894     // figure out play time for all discs, and sum it
    895     // we do this via lookups, rather than the other entries, because of duplicates
    896     for (const std::string& set_serial : dbentry->disc_set_serials)
    897     {
    898       const auto it = played_time_map.find(set_serial);
    899       if (it == played_time_map.end())
    900         continue;
    901 
    902       set_entry.last_played_time =
    903         (set_entry.last_played_time == 0) ?
    904           it->second.last_played_time :
    905           ((it->second.last_played_time != 0) ? std::max(set_entry.last_played_time, it->second.last_played_time) :
    906                                                 set_entry.last_played_time);
    907       set_entry.total_played_time += it->second.total_played_time;
    908     }
    909 
    910     // mark all discs for this set as part of it, so we don't try to add them again, and for filtering
    911     u32 num_parts = 0;
    912     for (Entry& other_entry : s_entries)
    913     {
    914       if (other_entry.type != EntryType::Disc || other_entry.disc_set_member ||
    915           other_entry.disc_set_name != disc_set_name)
    916       {
    917         continue;
    918       }
    919 
    920       DEV_LOG("Adding {} to disc set {}", Path::GetFileName(other_entry.path), disc_set_name);
    921       other_entry.disc_set_member = true;
    922       set_entry.last_modified_time = std::min(set_entry.last_modified_time, other_entry.last_modified_time);
    923       set_entry.file_size += other_entry.file_size;
    924       set_entry.uncompressed_size += other_entry.uncompressed_size;
    925       num_parts++;
    926     }
    927 
    928     DEV_LOG("Created disc set {} from {} entries", disc_set_name, num_parts);
    929 
    930     // we have to do the exclusion check at the end, because otherwise the individual discs get added
    931     if (!IsPathExcluded(excluded_paths, disc_set_name))
    932       s_entries.push_back(std::move(set_entry));
    933   }
    934 }
    935 
    936 std::string GameList::GetCoverImagePathForEntry(const Entry* entry)
    937 {
    938   return GetCoverImagePath(entry->path, entry->serial, entry->title);
    939 }
    940 
    941 static std::string GetFullCoverPath(std::string_view filename, std::string_view extension)
    942 {
    943   return fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}.{}", EmuFolders::Covers, filename, extension);
    944 }
    945 
    946 std::string GameList::GetCoverImagePath(const std::string& path, const std::string& serial, const std::string& title)
    947 {
    948   static constexpr const std::array extensions = {"jpg", "jpeg", "png", "webp"};
    949 
    950   for (const char* extension : extensions)
    951   {
    952     // Prioritize lookup by serial (Most specific)
    953     if (!serial.empty())
    954     {
    955       const std::string cover_path(GetFullCoverPath(serial, extension));
    956       if (FileSystem::FileExists(cover_path.c_str()))
    957         return cover_path;
    958     }
    959 
    960     // Try file title (for modded games or specific like above)
    961     const std::string_view file_title(Path::GetFileTitle(path));
    962     if (!file_title.empty() && title != file_title)
    963     {
    964       const std::string cover_path(GetFullCoverPath(file_title, extension));
    965       if (FileSystem::FileExists(cover_path.c_str()))
    966         return cover_path;
    967     }
    968 
    969     // Last resort, check the game title
    970     if (!title.empty())
    971     {
    972       const std::string cover_path(GetFullCoverPath(title, extension));
    973       if (FileSystem::FileExists(cover_path.c_str()))
    974         return cover_path;
    975     }
    976   }
    977 
    978   return {};
    979 }
    980 
    981 std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial)
    982 {
    983   const char* extension = std::strrchr(new_filename, '.');
    984   if (!extension)
    985     return {};
    986 
    987   std::string existing_filename = GetCoverImagePathForEntry(entry);
    988   if (!existing_filename.empty())
    989   {
    990     std::string::size_type pos = existing_filename.rfind('.');
    991     if (pos != std::string::npos && existing_filename.compare(pos, std::strlen(extension), extension) == 0)
    992       return existing_filename;
    993   }
    994 
    995   // Check for illegal characters, use serial instead.
    996   const std::string sanitized_name(Path::SanitizeFileName(entry->title));
    997 
    998   std::string name;
    999   if (sanitized_name != entry->title || use_serial)
   1000     name = fmt::format("{}{}", entry->serial, extension);
   1001   else
   1002     name = fmt::format("{}{}", entry->title, extension);
   1003 
   1004   return Path::Combine(EmuFolders::Covers, Path::SanitizeFileName(name));
   1005 }
   1006 
   1007 size_t GameList::Entry::GetReleaseDateString(char* buffer, size_t buffer_size) const
   1008 {
   1009   if (release_date == 0)
   1010     return StringUtil::Strlcpy(buffer, "Unknown", buffer_size);
   1011 
   1012   std::time_t date_as_time = static_cast<std::time_t>(release_date);
   1013 #ifdef _WIN32
   1014   tm date_tm = {};
   1015   gmtime_s(&date_tm, &date_as_time);
   1016 #else
   1017   tm date_tm = {};
   1018   gmtime_r(&date_as_time, &date_tm);
   1019 #endif
   1020 
   1021   return std::strftime(buffer, buffer_size, "%d %B %Y", &date_tm);
   1022 }
   1023 
   1024 std::string GameList::GetPlayedTimeFile()
   1025 {
   1026   return Path::Combine(EmuFolders::DataRoot, "playtime.dat");
   1027 }
   1028 
   1029 bool GameList::ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry)
   1030 {
   1031   size_t len = std::strlen(line);
   1032   if (len != (PLAYED_TIME_LINE_LENGTH + 1)) // \n
   1033   {
   1034     WARNING_LOG("Malformed line: '{}'", line);
   1035     return false;
   1036   }
   1037 
   1038   const std::string_view serial_tok(StringUtil::StripWhitespace(std::string_view(line, PLAYED_TIME_SERIAL_LENGTH)));
   1039   const std::string_view total_played_time_tok(
   1040     StringUtil::StripWhitespace(std::string_view(line + PLAYED_TIME_SERIAL_LENGTH + 1, PLAYED_TIME_LAST_TIME_LENGTH)));
   1041   const std::string_view last_played_time_tok(StringUtil::StripWhitespace(std::string_view(
   1042     line + PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1, PLAYED_TIME_TOTAL_TIME_LENGTH)));
   1043 
   1044   const std::optional<u64> total_played_time(StringUtil::FromChars<u64>(total_played_time_tok));
   1045   const std::optional<u64> last_played_time(StringUtil::FromChars<u64>(last_played_time_tok));
   1046   if (serial_tok.empty() || !last_played_time.has_value() || !total_played_time.has_value())
   1047   {
   1048     WARNING_LOG("Malformed line: '{}'", line);
   1049     return false;
   1050   }
   1051 
   1052   serial = serial_tok;
   1053   entry.last_played_time = static_cast<std::time_t>(last_played_time.value());
   1054   entry.total_played_time = static_cast<std::time_t>(total_played_time.value());
   1055   return true;
   1056 }
   1057 
   1058 std::string GameList::MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry)
   1059 {
   1060   return fmt::format("{:<{}} {:<{}} {:<{}}\n", serial, static_cast<unsigned>(PLAYED_TIME_SERIAL_LENGTH),
   1061                      entry.total_played_time, static_cast<unsigned>(PLAYED_TIME_TOTAL_TIME_LENGTH),
   1062                      entry.last_played_time, static_cast<unsigned>(PLAYED_TIME_LAST_TIME_LENGTH));
   1063 }
   1064 
   1065 GameList::PlayedTimeMap GameList::LoadPlayedTimeMap(const std::string& path)
   1066 {
   1067   PlayedTimeMap ret;
   1068 
   1069   // Use write mode here, even though we're not writing, so we can lock the file from other updates.
   1070   Error error;
   1071   auto fp = FileSystem::OpenExistingOrCreateManagedCFile(path.c_str(), 0, &error);
   1072   if (!fp)
   1073   {
   1074     ERROR_LOG("Failed to open '{}' for load: {}", Path::GetFileName(path), error.GetDescription());
   1075     return ret;
   1076   }
   1077 
   1078 #ifndef _WIN32
   1079   FileSystem::POSIXLock flock(fp.get());
   1080 #endif
   1081 
   1082   char line[256];
   1083   while (std::fgets(line, sizeof(line), fp.get()))
   1084   {
   1085     std::string serial;
   1086     PlayedTimeEntry entry;
   1087     if (!ParsePlayedTimeLine(line, serial, entry))
   1088       continue;
   1089 
   1090     if (ret.find(serial) != ret.end())
   1091     {
   1092       WARNING_LOG("Duplicate entry: '{}'", serial);
   1093       continue;
   1094     }
   1095 
   1096     ret.emplace(std::move(serial), entry);
   1097   }
   1098 
   1099   return ret;
   1100 }
   1101 
   1102 GameList::PlayedTimeEntry GameList::UpdatePlayedTimeFile(const std::string& path, const std::string& serial,
   1103                                                          std::time_t last_time, std::time_t add_time)
   1104 {
   1105   const PlayedTimeEntry new_entry{last_time, add_time};
   1106 
   1107   Error error;
   1108   auto fp = FileSystem::OpenExistingOrCreateManagedCFile(path.c_str(), 0, &error);
   1109   if (!fp)
   1110   {
   1111     ERROR_LOG("Failed to open '{}' for update: {}", Path::GetFileName(path), error.GetDescription());
   1112     return new_entry;
   1113   }
   1114 
   1115 #ifndef _WIN32
   1116   FileSystem::POSIXLock flock(fp.get());
   1117 #endif
   1118 
   1119   for (;;)
   1120   {
   1121     char line[256];
   1122     const s64 line_pos = FileSystem::FTell64(fp.get());
   1123     if (!std::fgets(line, sizeof(line), fp.get()))
   1124       break;
   1125 
   1126     std::string line_serial;
   1127     PlayedTimeEntry line_entry;
   1128     if (!ParsePlayedTimeLine(line, line_serial, line_entry))
   1129       continue;
   1130 
   1131     if (line_serial != serial)
   1132       continue;
   1133 
   1134     // found it!
   1135     line_entry.last_played_time = (last_time != 0) ? last_time : 0;
   1136     line_entry.total_played_time = (last_time != 0) ? (line_entry.total_played_time + add_time) : 0;
   1137 
   1138     std::string new_line(MakePlayedTimeLine(serial, line_entry));
   1139     if (FileSystem::FSeek64(fp.get(), line_pos, SEEK_SET) != 0 ||
   1140         std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1)
   1141     {
   1142       ERROR_LOG("Failed to update '{}'.", path);
   1143     }
   1144 
   1145     return line_entry;
   1146   }
   1147 
   1148   if (last_time != 0)
   1149   {
   1150     // new entry.
   1151     std::string new_line(MakePlayedTimeLine(serial, new_entry));
   1152     if (FileSystem::FSeek64(fp.get(), 0, SEEK_END) != 0 ||
   1153         std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1)
   1154     {
   1155       ERROR_LOG("Failed to write '{}'.", path);
   1156     }
   1157   }
   1158 
   1159   return new_entry;
   1160 }
   1161 
   1162 void GameList::AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time)
   1163 {
   1164   if (serial.empty())
   1165     return;
   1166 
   1167   const PlayedTimeEntry pt(UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, last_time, add_time));
   1168   VERBOSE_LOG("Add {} seconds play time to {} -> now {}", static_cast<unsigned>(add_time), serial.c_str(),
   1169               static_cast<unsigned>(pt.total_played_time));
   1170 
   1171   std::unique_lock<std::recursive_mutex> lock(s_mutex);
   1172   for (GameList::Entry& entry : s_entries)
   1173   {
   1174     if (entry.serial != serial)
   1175       continue;
   1176 
   1177     entry.last_played_time = pt.last_played_time;
   1178     entry.total_played_time = pt.total_played_time;
   1179   }
   1180 
   1181   // We don't need to update the disc sets if we're not running Big Picture, because Qt refreshes on system destory,
   1182   // which causes the disc set entries to get recreated.
   1183   if (FullscreenUI::IsInitialized())
   1184   {
   1185     const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(serial);
   1186     if (dbentry && !dbentry->disc_set_serials.empty())
   1187     {
   1188       for (GameList::Entry& entry : s_entries)
   1189       {
   1190         if (entry.type != EntryType::DiscSet || entry.path != dbentry->disc_set_name)
   1191           continue;
   1192 
   1193         entry.last_played_time = 0;
   1194         entry.total_played_time = 0;
   1195 
   1196         // We shouldn't ever have duplicates for disc sets, so this should be fine.
   1197         const PlayedTimeMap ptm = LoadPlayedTimeMap(GetPlayedTimeFile());
   1198         for (const std::string& dsserial : dbentry->disc_set_serials)
   1199         {
   1200           const auto it = ptm.find(dsserial);
   1201           if (it == ptm.end())
   1202             continue;
   1203 
   1204           entry.last_played_time =
   1205             (entry.last_played_time == 0) ?
   1206               it->second.last_played_time :
   1207               ((it->second.last_played_time != 0) ? std::max(entry.last_played_time, it->second.last_played_time) :
   1208                                                     entry.last_played_time);
   1209           entry.total_played_time += it->second.total_played_time;
   1210         }
   1211 
   1212         break;
   1213       }
   1214     }
   1215   }
   1216 }
   1217 
   1218 void GameList::ClearPlayedTimeForSerial(const std::string& serial)
   1219 {
   1220   if (serial.empty())
   1221     return;
   1222 
   1223   UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, 0, 0);
   1224 
   1225   std::unique_lock<std::recursive_mutex> lock(s_mutex);
   1226   for (GameList::Entry& entry : s_entries)
   1227   {
   1228     if (entry.serial != serial)
   1229       continue;
   1230 
   1231     entry.last_played_time = 0;
   1232     entry.total_played_time = 0;
   1233   }
   1234 }
   1235 
   1236 std::time_t GameList::GetCachedPlayedTimeForSerial(const std::string& serial)
   1237 {
   1238   if (serial.empty())
   1239     return 0;
   1240 
   1241   std::unique_lock<std::recursive_mutex> lock(s_mutex);
   1242   for (GameList::Entry& entry : s_entries)
   1243   {
   1244     if (entry.serial == serial)
   1245       return entry.total_played_time;
   1246   }
   1247 
   1248   return 0;
   1249 }
   1250 
   1251 TinyString GameList::FormatTimestamp(std::time_t timestamp)
   1252 {
   1253   TinyString ret;
   1254 
   1255   if (timestamp == 0)
   1256   {
   1257     ret = TRANSLATE("GameList", "Never");
   1258   }
   1259   else
   1260   {
   1261     struct tm ctime = {};
   1262     struct tm ttime = {};
   1263     const std::time_t ctimestamp = std::time(nullptr);
   1264 #ifdef _MSC_VER
   1265     localtime_s(&ctime, &ctimestamp);
   1266     localtime_s(&ttime, &timestamp);
   1267 #else
   1268     localtime_r(&ctimestamp, &ctime);
   1269     localtime_r(&timestamp, &ttime);
   1270 #endif
   1271 
   1272     if (ctime.tm_year == ttime.tm_year && ctime.tm_yday == ttime.tm_yday)
   1273     {
   1274       ret = TRANSLATE("GameList", "Today");
   1275     }
   1276     else if ((ctime.tm_year == ttime.tm_year && ctime.tm_yday == (ttime.tm_yday + 1)) ||
   1277              (ctime.tm_yday == 0 && (ctime.tm_year - 1) == ttime.tm_year))
   1278     {
   1279       ret = TRANSLATE("GameList", "Yesterday");
   1280     }
   1281     else
   1282     {
   1283       char buf[128];
   1284       std::strftime(buf, std::size(buf), "%x", &ttime);
   1285       ret.assign(buf);
   1286     }
   1287   }
   1288 
   1289   return ret;
   1290 }
   1291 
   1292 TinyString GameList::FormatTimespan(std::time_t timespan, bool long_format)
   1293 {
   1294   const u32 hours = static_cast<u32>(timespan / 3600);
   1295   const u32 minutes = static_cast<u32>((timespan % 3600) / 60);
   1296   const u32 seconds = static_cast<u32>((timespan % 3600) % 60);
   1297 
   1298   TinyString ret;
   1299   if (!long_format)
   1300   {
   1301     if (hours >= 100)
   1302       ret.format(TRANSLATE_FS("GameList", "{}h {}m"), hours, minutes);
   1303     else if (hours > 0)
   1304       ret.format(TRANSLATE_FS("GameList", "{}h {}m {}s"), hours, minutes, seconds);
   1305     else if (minutes > 0)
   1306       ret.format(TRANSLATE_FS("GameList", "{}m {}s"), minutes, seconds);
   1307     else if (seconds > 0)
   1308       ret.format(TRANSLATE_FS("GameList", "{}s"), seconds);
   1309     else
   1310       ret = TRANSLATE_SV("GameList", "None");
   1311   }
   1312   else
   1313   {
   1314     if (hours > 0)
   1315       ret.assign(TRANSLATE_PLURAL_STR("GameList", "%n hours", "", hours));
   1316     else
   1317       ret.assign(TRANSLATE_PLURAL_STR("GameList", "%n minutes", "", minutes));
   1318   }
   1319 
   1320   return ret;
   1321 }
   1322 
   1323 std::vector<std::pair<std::string, const GameList::Entry*>>
   1324 GameList::GetMatchingEntriesForSerial(const std::span<const std::string> serials)
   1325 {
   1326   std::vector<std::pair<std::string, const GameList::Entry*>> ret;
   1327   ret.reserve(serials.size());
   1328 
   1329   for (const std::string& serial : serials)
   1330   {
   1331     const Entry* matching_entry = nullptr;
   1332     bool has_multiple_entries = false;
   1333 
   1334     for (const Entry& entry : s_entries)
   1335     {
   1336       if (entry.IsDiscSet() || entry.serial != serial)
   1337         continue;
   1338 
   1339       if (!matching_entry)
   1340         matching_entry = &entry;
   1341       else
   1342         has_multiple_entries = true;
   1343     }
   1344 
   1345     if (!matching_entry)
   1346       continue;
   1347 
   1348     if (!has_multiple_entries)
   1349     {
   1350       ret.emplace_back(matching_entry->title, matching_entry);
   1351       continue;
   1352     }
   1353 
   1354     // Have to add all matching files.
   1355     for (const Entry& entry : s_entries)
   1356     {
   1357       if (entry.IsDiscSet() || entry.serial != serial)
   1358         continue;
   1359 
   1360       ret.emplace_back(Path::GetFileName(entry.path), &entry);
   1361     }
   1362   }
   1363 
   1364   return ret;
   1365 }
   1366 
   1367 bool GameList::DownloadCovers(const std::vector<std::string>& url_templates, bool use_serial,
   1368                               ProgressCallback* progress, std::function<void(const Entry*, std::string)> save_callback)
   1369 {
   1370   if (!progress)
   1371     progress = ProgressCallback::NullProgressCallback;
   1372 
   1373   bool has_title = false;
   1374   bool has_file_title = false;
   1375   bool has_serial = false;
   1376   for (const std::string& url_template : url_templates)
   1377   {
   1378     if (!has_title && url_template.find("${title}") != std::string::npos)
   1379       has_title = true;
   1380     if (!has_file_title && url_template.find("${filetitle}") != std::string::npos)
   1381       has_file_title = true;
   1382     if (!has_serial && url_template.find("${serial}") != std::string::npos)
   1383       has_serial = true;
   1384   }
   1385   if (!has_title && !has_file_title && !has_serial)
   1386   {
   1387     progress->DisplayError("URL template must contain at least one of ${title}, ${filetitle}, or ${serial}.");
   1388     return false;
   1389   }
   1390 
   1391   std::vector<std::pair<std::string, std::string>> download_urls;
   1392   {
   1393     std::unique_lock lock(s_mutex);
   1394     for (const GameList::Entry& entry : s_entries)
   1395     {
   1396       const std::string existing_path(GetCoverImagePathForEntry(&entry));
   1397       if (!existing_path.empty())
   1398         continue;
   1399 
   1400       for (const std::string& url_template : url_templates)
   1401       {
   1402         std::string url(url_template);
   1403         if (has_title)
   1404           StringUtil::ReplaceAll(&url, "${title}", Path::URLEncode(entry.title));
   1405         if (has_file_title)
   1406         {
   1407           std::string display_name(FileSystem::GetDisplayNameFromPath(entry.path));
   1408           StringUtil::ReplaceAll(&url, "${filetitle}", Path::URLEncode(Path::GetFileTitle(display_name)));
   1409         }
   1410         if (has_serial)
   1411           StringUtil::ReplaceAll(&url, "${serial}", Path::URLEncode(entry.serial));
   1412 
   1413         download_urls.emplace_back(entry.path, std::move(url));
   1414       }
   1415     }
   1416   }
   1417   if (download_urls.empty())
   1418   {
   1419     progress->DisplayError("No URLs to download enumerated.");
   1420     return false;
   1421   }
   1422 
   1423   std::unique_ptr<HTTPDownloader> downloader(HTTPDownloader::Create(Host::GetHTTPUserAgent()));
   1424   if (!downloader)
   1425   {
   1426     progress->DisplayError("Failed to create HTTP downloader.");
   1427     return false;
   1428   }
   1429 
   1430   progress->SetCancellable(true);
   1431   progress->SetProgressRange(static_cast<u32>(download_urls.size()));
   1432 
   1433   for (auto& [entry_path, url] : download_urls)
   1434   {
   1435     if (progress->IsCancelled())
   1436       break;
   1437 
   1438     // make sure it didn't get done already
   1439     {
   1440       std::unique_lock lock(s_mutex);
   1441       const GameList::Entry* entry = GetEntryForPath(entry_path);
   1442       if (!entry || !GetCoverImagePathForEntry(entry).empty())
   1443       {
   1444         progress->IncrementProgressValue();
   1445         continue;
   1446       }
   1447 
   1448       progress->FormatStatusText("Downloading cover for {}...", entry->title);
   1449     }
   1450 
   1451     // we could actually do a few in parallel here...
   1452     std::string filename = Path::URLDecode(url);
   1453     downloader->CreateRequest(
   1454       std::move(url), [use_serial, &save_callback, entry_path = std::move(entry_path), filename = std::move(filename)](
   1455                         s32 status_code, const std::string& content_type, HTTPDownloader::Request::Data data) {
   1456         if (status_code != HTTPDownloader::HTTP_STATUS_OK || data.empty())
   1457           return;
   1458 
   1459         std::unique_lock lock(s_mutex);
   1460         const GameList::Entry* entry = GetEntryForPath(entry_path);
   1461         if (!entry || !GetCoverImagePathForEntry(entry).empty())
   1462           return;
   1463 
   1464         // prefer the content type from the response for the extension
   1465         // otherwise, if it's missing, and the request didn't have an extension.. fall back to jpegs.
   1466         std::string template_filename;
   1467         std::string content_type_extension(HTTPDownloader::GetExtensionForContentType(content_type));
   1468 
   1469         // don't treat the domain name as an extension..
   1470         const std::string::size_type last_slash = filename.find('/');
   1471         const std::string::size_type last_dot = filename.find('.');
   1472         if (!content_type_extension.empty())
   1473           template_filename = fmt::format("cover.{}", content_type_extension);
   1474         else if (last_slash != std::string::npos && last_dot != std::string::npos && last_dot > last_slash)
   1475           template_filename = Path::GetFileName(filename);
   1476         else
   1477           template_filename = "cover.jpg";
   1478 
   1479         std::string write_path(GetNewCoverImagePathForEntry(entry, template_filename.c_str(), use_serial));
   1480         if (write_path.empty())
   1481           return;
   1482 
   1483         if (FileSystem::WriteBinaryFile(write_path.c_str(), data.data(), data.size()) && save_callback)
   1484           save_callback(entry, std::move(write_path));
   1485       });
   1486     downloader->WaitForAllRequests();
   1487     progress->IncrementProgressValue();
   1488   }
   1489 
   1490   return true;
   1491 }
   1492 
   1493 std::string GameList::GetCustomPropertiesFile()
   1494 {
   1495   return Path::Combine(EmuFolders::DataRoot, "custom_properties.ini");
   1496 }
   1497 
   1498 void GameList::SaveCustomTitleForPath(const std::string& path, const std::string& custom_title)
   1499 {
   1500   INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
   1501   custom_attributes_ini.Load();
   1502 
   1503   if (!custom_title.empty())
   1504   {
   1505     custom_attributes_ini.SetStringValue(path.c_str(), "Title", custom_title.c_str());
   1506   }
   1507   else
   1508   {
   1509     custom_attributes_ini.DeleteValue(path.c_str(), "Title");
   1510     custom_attributes_ini.RemoveEmptySections();
   1511   }
   1512 
   1513   Error error;
   1514   if (!custom_attributes_ini.Save(&error))
   1515   {
   1516     ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription());
   1517     return;
   1518   }
   1519 
   1520   if (!custom_title.empty())
   1521   {
   1522     // Can skip the rescan and just update the value directly.
   1523     auto lock = GetLock();
   1524     Entry* entry = GetMutableEntryForPath(path);
   1525     if (entry)
   1526     {
   1527       entry->title = custom_title;
   1528       entry->has_custom_title = true;
   1529     }
   1530   }
   1531   else
   1532   {
   1533     // Let the cache update by rescanning. Only need to do this on deletion, to get the original value.
   1534     RescanCustomAttributesForPath(path, custom_attributes_ini);
   1535   }
   1536 }
   1537 
   1538 void GameList::SaveCustomRegionForPath(const std::string& path, const std::optional<DiscRegion> custom_region)
   1539 {
   1540   INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
   1541   custom_attributes_ini.Load();
   1542 
   1543   if (custom_region.has_value())
   1544   {
   1545     custom_attributes_ini.SetStringValue(path.c_str(), "Region", Settings::GetDiscRegionName(custom_region.value()));
   1546   }
   1547   else
   1548   {
   1549     custom_attributes_ini.DeleteValue(path.c_str(), "Region");
   1550     custom_attributes_ini.RemoveEmptySections();
   1551   }
   1552 
   1553   Error error;
   1554   if (!custom_attributes_ini.Save(&error))
   1555   {
   1556     ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription());
   1557     return;
   1558   }
   1559 
   1560   if (custom_region.has_value())
   1561   {
   1562     // Can skip the rescan and just update the value directly.
   1563     auto lock = GetLock();
   1564     Entry* entry = GetMutableEntryForPath(path);
   1565     if (entry)
   1566     {
   1567       entry->region = custom_region.value();
   1568       entry->has_custom_region = true;
   1569     }
   1570   }
   1571   else
   1572   {
   1573     // Let the cache update by rescanning. Only need to do this on deletion, to get the original value.
   1574     RescanCustomAttributesForPath(path, custom_attributes_ini);
   1575   }
   1576 }
   1577 
   1578 std::string GameList::GetCustomTitleForPath(const std::string_view path)
   1579 {
   1580   std::string ret;
   1581 
   1582   std::unique_lock lock(s_mutex);
   1583   const GameList::Entry* entry = GetEntryForPath(path);
   1584   if (entry && entry->has_custom_title)
   1585     ret = entry->title;
   1586 
   1587   return ret;
   1588 }
   1589 
   1590 std::optional<DiscRegion> GameList::GetCustomRegionForPath(const std::string_view path)
   1591 {
   1592   const GameList::Entry* entry = GetEntryForPath(path);
   1593   if (entry && entry->has_custom_region)
   1594     return entry->region;
   1595   else
   1596     return std::nullopt;
   1597 }
   1598 
   1599 static constexpr const char MEMCARD_TIMESTAMP_CACHE_SIGNATURE[] = {'M', 'C', 'D', 'I', 'C', 'N', '0', '3'};
   1600 
   1601 FileSystem::ManagedCFilePtr GameList::OpenMemoryCardTimestampCache(bool for_write)
   1602 {
   1603   const std::string filename = Path::Combine(EmuFolders::Cache, "memcard_icons.cache");
   1604   const FileSystem::FileShareMode share_mode =
   1605     for_write ? FileSystem::FileShareMode::DenyReadWrite : FileSystem::FileShareMode::DenyWrite;
   1606 #ifdef _WIN32
   1607   const char* mode = for_write ? "r+b" : "rb";
   1608 #else
   1609   // Always open read/write on Linux, since we need it for flock().
   1610   const char* mode = "r+b";
   1611 #endif
   1612 
   1613   FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr);
   1614   if (fp)
   1615     return fp;
   1616 
   1617   // Doesn't exist? Create it.
   1618   if (errno == ENOENT)
   1619   {
   1620     if (!for_write)
   1621       return nullptr;
   1622 
   1623     mode = "w+b";
   1624     fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr);
   1625     if (fp)
   1626       return fp;
   1627   }
   1628 
   1629   // If there's a sharing violation, try again for 100ms.
   1630   if (errno != EACCES)
   1631     return nullptr;
   1632 
   1633   Common::Timer timer;
   1634   while (timer.GetTimeMilliseconds() <= 100.0f)
   1635   {
   1636     fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr);
   1637     if (fp)
   1638       return fp;
   1639 
   1640     if (errno != EACCES)
   1641       return nullptr;
   1642   }
   1643 
   1644   ERROR_LOG("Timed out while trying to open memory card cache file.");
   1645   return nullptr;
   1646 }
   1647 
   1648 void GameList::ReloadMemcardTimestampCache()
   1649 {
   1650   s_memcard_timestamp_cache_entries.clear();
   1651 
   1652   FileSystem::ManagedCFilePtr fp = OpenMemoryCardTimestampCache(false);
   1653   if (!fp)
   1654     return;
   1655 
   1656 #ifndef _WIN32
   1657   FileSystem::POSIXLock lock(fp.get());
   1658 #endif
   1659 
   1660   const s64 file_size = FileSystem::FSize64(fp.get());
   1661   if (file_size < static_cast<s64>(sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)))
   1662     return;
   1663 
   1664   const size_t count =
   1665     (static_cast<size_t>(file_size) - sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)) / sizeof(MemcardTimestampCacheEntry);
   1666   if (count <= 0)
   1667     return;
   1668 
   1669   char signature[sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)];
   1670   if (std::fread(signature, sizeof(signature), 1, fp.get()) != 1 ||
   1671       std::memcmp(signature, MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(signature)) != 0)
   1672   {
   1673     return;
   1674   }
   1675 
   1676   s_memcard_timestamp_cache_entries.resize(static_cast<size_t>(count));
   1677   if (std::fread(s_memcard_timestamp_cache_entries.data(), sizeof(MemcardTimestampCacheEntry),
   1678                  s_memcard_timestamp_cache_entries.size(), fp.get()) != s_memcard_timestamp_cache_entries.size())
   1679   {
   1680     s_memcard_timestamp_cache_entries = {};
   1681     return;
   1682   }
   1683 
   1684   // Just in case.
   1685   for (MemcardTimestampCacheEntry& entry : s_memcard_timestamp_cache_entries)
   1686     entry.serial[sizeof(entry.serial) - 1] = 0;
   1687 }
   1688 
   1689 std::string GameList::GetGameIconPath(std::string_view serial, std::string_view path)
   1690 {
   1691   std::string ret;
   1692 
   1693   if (serial.empty())
   1694     return ret;
   1695 
   1696   // might exist already, or the user used a custom icon
   1697   ret = Path::Combine(EmuFolders::GameIcons, TinyString::from_format("{}.png", serial));
   1698   if (FileSystem::FileExists(ret.c_str()))
   1699     return ret;
   1700 
   1701   MemoryCardType type;
   1702   std::string memcard_path = System::GetGameMemoryCardPath(serial, path, 0, &type);
   1703   FILESYSTEM_STAT_DATA memcard_sd;
   1704   if (memcard_path.empty() || type == MemoryCardType::Shared ||
   1705       !FileSystem::StatFile(memcard_path.c_str(), &memcard_sd))
   1706   {
   1707     ret = {};
   1708     return ret;
   1709   }
   1710 
   1711   const s64 timestamp = memcard_sd.ModificationTime;
   1712   TinyString index_serial;
   1713   index_serial.assign(
   1714     serial.substr(0, std::min<size_t>(serial.length(), MemcardTimestampCacheEntry::MAX_SERIAL_LENGTH - 1)));
   1715 
   1716   MemcardTimestampCacheEntry* serial_entry = nullptr;
   1717   for (MemcardTimestampCacheEntry& entry : s_memcard_timestamp_cache_entries)
   1718   {
   1719     if (StringUtil::EqualNoCase(index_serial, entry.serial))
   1720     {
   1721       // user might've deleted the file, so re-extract it if so
   1722       // otherwise, card hasn't changed, still no icon
   1723       if (entry.memcard_timestamp == timestamp && !entry.icon_was_extracted)
   1724       {
   1725         ret = {};
   1726         return ret;
   1727       }
   1728 
   1729       serial_entry = &entry;
   1730       break;
   1731     }
   1732   }
   1733 
   1734   if (!serial_entry)
   1735   {
   1736     serial_entry = &s_memcard_timestamp_cache_entries.emplace_back();
   1737     std::memset(serial_entry, 0, sizeof(MemcardTimestampCacheEntry));
   1738   }
   1739 
   1740   serial_entry->memcard_timestamp = timestamp;
   1741   serial_entry->icon_was_extracted = false;
   1742   StringUtil::Strlcpy(serial_entry->serial, index_serial.view(), sizeof(serial_entry->serial));
   1743 
   1744   // Try extracting an icon.
   1745   Error error;
   1746   std::unique_ptr<MemoryCardImage::DataArray> data = std::make_unique<MemoryCardImage::DataArray>();
   1747   if (MemoryCardImage::LoadFromFile(data.get(), memcard_path.c_str(), &error))
   1748   {
   1749     std::vector<MemoryCardImage::FileInfo> files = MemoryCardImage::EnumerateFiles(*data.get(), false);
   1750     if (!files.empty())
   1751     {
   1752       const MemoryCardImage::FileInfo& fi = files.front();
   1753       if (!fi.icon_frames.empty())
   1754       {
   1755         INFO_LOG("Extracting memory card icon from {} ({}) to {}", fi.filename, Path::GetFileTitle(memcard_path),
   1756                  Path::GetFileTitle(ret));
   1757 
   1758         RGBA8Image image(MemoryCardImage::ICON_WIDTH, MemoryCardImage::ICON_HEIGHT);
   1759         std::memcpy(image.GetPixels(), &fi.icon_frames.front().pixels,
   1760                     MemoryCardImage::ICON_WIDTH * MemoryCardImage::ICON_HEIGHT * sizeof(u32));
   1761         serial_entry->icon_was_extracted = image.SaveToFile(ret.c_str());
   1762         if (!serial_entry->icon_was_extracted)
   1763         {
   1764           ERROR_LOG("Failed to save memory card icon to {}.", ret);
   1765           ret = {};
   1766           return ret;
   1767         }
   1768       }
   1769     }
   1770   }
   1771   else
   1772   {
   1773     ERROR_LOG("Failed to load memory card '{}': {}", Path::GetFileName(memcard_path), error.GetDescription());
   1774   }
   1775 
   1776   UpdateMemcardTimestampCache(*serial_entry);
   1777   return ret;
   1778 }
   1779 
   1780 bool GameList::UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry)
   1781 {
   1782   FileSystem::ManagedCFilePtr fp = OpenMemoryCardTimestampCache(true);
   1783   if (!fp)
   1784     return false;
   1785 
   1786 #ifndef _WIN32
   1787   FileSystem::POSIXLock lock(fp.get());
   1788 #endif
   1789 
   1790   // check signature, write it if it's non-existent or invalid
   1791   char signature[sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)];
   1792   if (std::fread(signature, sizeof(signature), 1, fp.get()) != 1 ||
   1793       std::memcmp(signature, MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(signature)) != 0)
   1794   {
   1795     if (!FileSystem::FTruncate64(fp.get(), 0) || FileSystem::FSeek64(fp.get(), 0, SEEK_SET) != 0 ||
   1796         std::fwrite(MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE), 1, fp.get()) != 1)
   1797     {
   1798       return false;
   1799     }
   1800   }
   1801 
   1802   // need to seek to switch from read->write?
   1803   s64 current_pos = sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE);
   1804   if (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) != 0)
   1805     return false;
   1806 
   1807   for (;;)
   1808   {
   1809     MemcardTimestampCacheEntry existing_entry;
   1810     if (std::fread(&existing_entry, sizeof(existing_entry), 1, fp.get()) != 1)
   1811       break;
   1812 
   1813     existing_entry.serial[sizeof(existing_entry.serial) - 1] = 0;
   1814     if (!StringUtil::EqualNoCase(existing_entry.serial, entry.serial))
   1815     {
   1816       current_pos += sizeof(existing_entry);
   1817       continue;
   1818     }
   1819 
   1820     // found it here, so overwrite
   1821     return (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) == 0 &&
   1822             std::fwrite(&entry, sizeof(entry), 1, fp.get()) == 1);
   1823   }
   1824 
   1825   if (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) != 0)
   1826     return false;
   1827 
   1828   // append it.
   1829   return (std::fwrite(&entry, sizeof(entry), 1, fp.get()) == 1);
   1830 }