diff --git a/common/FileSystem.cpp b/common/FileSystem.cpp index b47d5064e6..aaf49b57a1 100644 --- a/common/FileSystem.cpp +++ b/common/FileSystem.cpp @@ -91,7 +91,7 @@ static inline bool FileSystemCharacterIsSane(char c, bool strip_slashes) return true; } -template +template static inline void PathAppendString(std::string& dst, const T& src) { if (dst.capacity() < (dst.length() + src.length())) @@ -100,7 +100,7 @@ static inline void PathAppendString(std::string& dst, const T& src) bool last_separator = (!dst.empty() && dst.back() == FS_OSPATH_SEPARATOR_CHARACTER); size_t index = 0; - + #ifdef _WIN32 // special case for UNC paths here if (dst.empty() && src.length() >= 3 && src[0] == '\\' && src[1] == '\\' && src[2] != '\\') @@ -192,7 +192,8 @@ bool Path::IsAbsolute(const std::string_view& path) { #ifdef _WIN32 return (path.length() >= 3 && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && - path[1] == ':' && (path[2] == '/' || path[2] == '\\')) || (path.length() >= 3 && path[0] == '\\' && path[1] == '\\'); + path[1] == ':' && (path[2] == '/' || path[2] == '\\')) || + (path.length() >= 3 && path[0] == '\\' && path[1] == '\\'); #else return (path.length() >= 1 && path[0] == '/'); #endif @@ -202,7 +203,7 @@ std::string Path::ToNativePath(const std::string_view& path) { std::string ret; PathAppendString(ret, path); - + // remove trailing slashes if (ret.length() > 1) { @@ -1930,4 +1931,36 @@ bool FileSystem::SetPathCompression(const char* path, bool enable) return false; } +FileSystem::POSIXLock::POSIXLock(int fd) +{ + if (lockf(fd, F_LOCK, 0) == 0) + { + m_fd = fd; + } + else + { + Console.Error("lockf() failed: %d", errno); + m_fd = -1; + } +} + +FileSystem::POSIXLock::POSIXLock(std::FILE* fp) +{ + m_fd = fileno(fp); + if (m_fd >= 0) + { + if (lockf(m_fd, F_LOCK, 0) != 0) + { + Console.Error("lockf() failed: %d", errno); + m_fd = -1; + } + } +} + +FileSystem::POSIXLock::~POSIXLock() +{ + if (m_fd >= 0) + lockf(m_fd, F_ULOCK, m_fd); +} + #endif diff --git a/common/FileSystem.h b/common/FileSystem.h index 958e09ad55..5804507bdc 100644 --- a/common/FileSystem.h +++ b/common/FileSystem.h @@ -164,4 +164,17 @@ namespace FileSystem /// Does nothing and returns false on non-Windows platforms. bool SetPathCompression(const char* path, bool enable); + /// Abstracts a POSIX file lock. +#ifndef _WIN32 + class POSIXLock + { + public: + POSIXLock(int fd); + POSIXLock(std::FILE* fp); + ~POSIXLock(); + + private: + int m_fd; + }; +#endif }; // namespace FileSystem diff --git a/common/HeterogeneousContainers.h b/common/HeterogeneousContainers.h index f19ec80360..133fa37c1f 100644 --- a/common/HeterogeneousContainers.h +++ b/common/HeterogeneousContainers.h @@ -19,6 +19,7 @@ #pragma once +#include "Pcsx2Defs.h" #include #include #include @@ -59,6 +60,8 @@ namespace detail }; } // namespace detail +// This requires C++20, so fallback to ugly heap allocations if we don't have it. +#if __cplusplus >= 202002L template using UnorderedStringMap = std::unordered_map; @@ -70,6 +73,38 @@ using UnorderedStringSet = using UnorderedStringMultiSet = std::unordered_multiset; +template +__fi typename UnorderedStringMap::const_iterator +UnorderedStringMapFind(const UnorderedStringMap& map, const KeyType& key) +{ + return map.find(key); +} +template +__fi typename UnorderedStringMap::iterator +UnorderedStringMapFind(UnorderedStringMap& map, const KeyType& key) +{ + return map.find(key); +} +#else +template +using UnorderedStringMap = std::unordered_map; +template +using UnorderedStringMultimap = std::unordered_multimap; +using UnorderedStringSet = std::unordered_set; +using UnorderedStringMultiSet = std::unordered_multiset; + +template +__fi typename UnorderedStringMap::const_iterator UnorderedStringMapFind(const UnorderedStringMap& map, const KeyType& key) +{ + return map.find(std::string(key)); +} +template +__fi typename UnorderedStringMap::iterator UnorderedStringMapFind(UnorderedStringMap& map, const KeyType& key) +{ + return map.find(std::string(key)); +} +#endif + template using StringMap = std::map; template diff --git a/pcsx2-qt/GameList/GameListModel.cpp b/pcsx2-qt/GameList/GameListModel.cpp index 5324e9c7c6..bf4c5102fb 100644 --- a/pcsx2-qt/GameList/GameListModel.cpp +++ b/pcsx2-qt/GameList/GameListModel.cpp @@ -32,7 +32,7 @@ #include static constexpr std::array s_column_names = { - {"Type", "Code", "Title", "File Title", "CRC", "Size", "Region", "Compatibility", "Cover"}}; + {"Type", "Code", "Title", "File Title", "CRC", "Time Played", "Last Played", "Size", "Region", "Compatibility", "Cover"}}; static constexpr int COVER_ART_WIDTH = 350; static constexpr int COVER_ART_HEIGHT = 512; @@ -176,7 +176,7 @@ void GameListModel::loadOrGenerateCover(const GameList::Entry* ge) // while there's outstanding jobs, the old jobs won't proceed (at the wrong size), or get added into the grid. const u32 counter = m_cover_scale_counter.load(std::memory_order_acquire); - QFuture future = QtConcurrent::run([this, path = ge->path, title = ge->title, serial = ge->serial, counter]()->QPixmap { + QFuture future = QtConcurrent::run([this, path = ge->path, title = ge->title, serial = ge->serial, counter]() -> QPixmap { QPixmap image; if (m_cover_scale_counter.load(std::memory_order_acquire) == counter) { @@ -299,6 +299,17 @@ QVariant GameListModel::data(const QModelIndex& index, int role) const case Column_CRC: return QString::fromStdString(fmt::format("{:08X}", ge->crc)); + case Column_TimePlayed: + { + if (ge->total_played_time == 0) + return {}; + else + return QString::fromStdString(GameList::FormatTimespan(ge->total_played_time, true)); + } + + case Column_LastPlayed: + return QString::fromStdString(GameList::FormatTimestamp(ge->last_played_time)); + case Column_Size: return QString("%1 MB").arg(static_cast(ge->total_size) / 1048576.0, 0, 'f', 2); @@ -335,6 +346,12 @@ QVariant GameListModel::data(const QModelIndex& index, int role) const case Column_CRC: return static_cast(ge->crc); + case Column_TimePlayed: + return static_cast(ge->total_played_time); + + case Column_LastPlayed: + return static_cast(ge->last_played_time); + case Column_Region: return static_cast(ge->region); @@ -504,6 +521,22 @@ bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& r return (left->crc < right->crc); } + case Column_TimePlayed: + { + if (left->total_played_time == right->total_played_time) + return titlesLessThan(left_row, right_row); + + return (left->total_played_time < right->total_played_time); + } + + case Column_LastPlayed: + { + if (left->last_played_time == right->last_played_time) + return titlesLessThan(left_row, right_row); + + return (left->last_played_time < right->last_played_time); + } + default: return false; } @@ -552,6 +585,8 @@ void GameListModel::setColumnDisplayNames() m_column_display_names[Column_Title] = tr("Title"); m_column_display_names[Column_FileTitle] = tr("File Title"); m_column_display_names[Column_CRC] = tr("CRC"); + m_column_display_names[Column_TimePlayed] = tr("Time Played"); + m_column_display_names[Column_LastPlayed] = tr("Last Played"); m_column_display_names[Column_Size] = tr("Size"); m_column_display_names[Column_Region] = tr("Region"); m_column_display_names[Column_Compatibility] = tr("Compatibility"); diff --git a/pcsx2-qt/GameList/GameListModel.h b/pcsx2-qt/GameList/GameListModel.h index 12a153b4b5..6837ec78aa 100644 --- a/pcsx2-qt/GameList/GameListModel.h +++ b/pcsx2-qt/GameList/GameListModel.h @@ -36,6 +36,8 @@ public: Column_Title, Column_FileTitle, Column_CRC, + Column_TimePlayed, + Column_LastPlayed, Column_Size, Column_Region, Column_Compatibility, diff --git a/pcsx2-qt/GameList/GameListWidget.cpp b/pcsx2-qt/GameList/GameListWidget.cpp index d3c0c4e227..19e8b1d309 100644 --- a/pcsx2-qt/GameList/GameListWidget.cpp +++ b/pcsx2-qt/GameList/GameListWidget.cpp @@ -482,6 +482,8 @@ void GameListWidget::resizeTableViewColumnsToFit() -1, // title -1, // file title 65, // crc + 80, // time played + 80, // last played 80, // size 60, // region 100 // compatibility @@ -502,6 +504,8 @@ void GameListWidget::loadTableViewColumnVisibilitySettings() true, // title false, // file title false, // crc + true, // time played + true, // last played true, // size true, // region true // compatibility diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index c52d3a1aa7..7cd40bee2d 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -1733,6 +1733,10 @@ void MainWindow::onVMStopped() { switchToGameListView(); } + + // reload played time + if (m_game_list_widget->isShowingGameList()) + m_game_list_widget->refresh(false); } void MainWindow::onGameChanged(const QString& path, const QString& serial, const QString& name, quint32 crc) diff --git a/pcsx2/Frontend/CommonHost.cpp b/pcsx2/Frontend/CommonHost.cpp index d854d888f6..29ef9ebab8 100644 --- a/pcsx2/Frontend/CommonHost.cpp +++ b/pcsx2/Frontend/CommonHost.cpp @@ -18,6 +18,7 @@ #include "common/CrashHandler.h" #include "common/FileSystem.h" #include "common/Path.h" +#include "common/Timer.h" #include "common/Threading.h" #include "Frontend/CommonHost.h" #include "Frontend/FullscreenUI.h" @@ -63,6 +64,8 @@ namespace CommonHost static void UpdateInhibitScreensaver(bool allow); + static void UpdateSessionTime(const std::string& new_serial); + #ifdef ENABLE_DISCORD_PRESENCE static void InitializeDiscordPresence(); static void ShutdownDiscordPresence(); @@ -71,6 +74,10 @@ namespace CommonHost #endif } // namespace CommonHost +// Used to track play time. We use a monotonic timer here, in case of clock changes. +static u64 s_session_start_time = 0; +static std::string s_session_serial; + static bool s_screensaver_inhibited = false; #ifdef ENABLE_DISCORD_PRESENCE @@ -361,6 +368,8 @@ void CommonHost::OnVMResumed() void CommonHost::OnGameChanged(const std::string& disc_path, const std::string& game_serial, const std::string& game_name, u32 game_crc) { + UpdateSessionTime(game_serial); + if (FullscreenUI::IsInitialized()) { GetMTGS().RunOnGSThread([disc_path, game_serial, game_name, game_crc]() { @@ -429,6 +438,30 @@ void CommonHost::UpdateInhibitScreensaver(bool inhibit) Console.Warning("Failed to inhibit screen saver."); } +void CommonHost::UpdateSessionTime(const std::string& new_serial) +{ + if (s_session_serial == new_serial) + return; + + const u64 ctime = Common::Timer::GetCurrentValue(); + if (!s_session_serial.empty()) + { + // round up to seconds + const std::time_t etime = static_cast(std::round(Common::Timer::ConvertValueToSeconds(ctime - s_session_start_time))); + const std::time_t wtime = std::time(nullptr); + GameList::AddPlayedTimeForSerial(s_session_serial, wtime, etime); + } + + s_session_serial = new_serial; + s_session_start_time = ctime; +} + +u64 CommonHost::GetSessionPlayedTime() +{ + const u64 ctime = Common::Timer::GetCurrentValue(); + return static_cast(std::round(Common::Timer::ConvertValueToSeconds(ctime - s_session_start_time))); +} + #ifdef ENABLE_DISCORD_PRESENCE void CommonHost::InitializeDiscordPresence() diff --git a/pcsx2/Frontend/CommonHost.h b/pcsx2/Frontend/CommonHost.h index 35aebf7f34..ef467b9383 100644 --- a/pcsx2/Frontend/CommonHost.h +++ b/pcsx2/Frontend/CommonHost.h @@ -76,6 +76,9 @@ namespace CommonHost /// Provided by the host; called once per frame at guest vsync. void CPUThreadVSync(); + /// Returns the time elapsed in the current play session. + u64 GetSessionPlayedTime(); + #ifdef ENABLE_DISCORD_PRESENCE /// Called when the rich presence string, provided by RetroAchievements, changes. void UpdateDiscordPresence(const std::string& rich_presence); diff --git a/pcsx2/Frontend/FullscreenUI.cpp b/pcsx2/Frontend/FullscreenUI.cpp index cd96420928..9d5d0cf120 100644 --- a/pcsx2/Frontend/FullscreenUI.cpp +++ b/pcsx2/Frontend/FullscreenUI.cpp @@ -17,6 +17,7 @@ #define IMGUI_DEFINE_MATH_OPERATORS +#include "Frontend/CommonHost.h" #include "Frontend/FullscreenUI.h" #include "Frontend/ImGuiManager.h" #include "Frontend/ImGuiFullscreen.h" @@ -3600,6 +3601,12 @@ void FullscreenUI::DrawGameFixesSettingsPage() EndMenuButtons(); } +static void DrawShadowedText(ImDrawList* dl, ImFont* font, const ImVec2& pos, u32 col, const char* text, const char* text_end = nullptr) +{ + dl->AddText(font, font->FontSize, pos + LayoutScale(1.0f, 1.0f), IM_COL32(0, 0, 0, 100), text, text_end); + dl->AddText(font, font->FontSize, pos, col, text, text_end); +} + void FullscreenUI::DrawPauseMenu(MainWindowType type) { ImDrawList* dl = ImGui::GetBackgroundDrawList(); @@ -3629,13 +3636,13 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) float rp_height = 0.0f; - dl->AddText(g_large_font, g_large_font->FontSize, title_pos, IM_COL32(255, 255, 255, 255), s_current_game_title.c_str()); + DrawShadowedText(dl, g_large_font, title_pos, IM_COL32(255, 255, 255, 255), s_current_game_title.c_str()); if (!path_string.empty()) { - dl->AddText(g_medium_font, g_medium_font->FontSize, path_pos, IM_COL32(255, 255, 255, 255), path_string.data(), + DrawShadowedText(dl, g_medium_font, path_pos, IM_COL32(255, 255, 255, 255), path_string.data(), path_string.data() + path_string.length()); } - dl->AddText(g_medium_font, g_medium_font->FontSize, subtitle_pos, IM_COL32(255, 255, 255, 255), s_current_game_subtitle.c_str()); + DrawShadowedText(dl, g_medium_font, subtitle_pos, IM_COL32(255, 255, 255, 255), s_current_game_subtitle.c_str()); HostDisplayTexture* const cover = GetCoverForCurrentGame(); @@ -3647,6 +3654,41 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) dl->AddImage(cover->GetHandle(), image_rect.Min, image_rect.Max); } + // current time / play time + { + char buf[256]; + struct tm ltime; + const std::time_t ctime(std::time(nullptr)); +#ifdef _MSC_VER + localtime_s(<ime, &ctime); +#else + localtime_r(&ctime, <ime); +#endif + std::strftime(buf, sizeof(buf), "%X", <ime); + + const ImVec2 time_size(g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits::max(), -1.0f, buf)); + const ImVec2 time_pos(display_size.x - LayoutScale(10.0f) - time_size.x, LayoutScale(10.0f)); + DrawShadowedText(dl, g_large_font, time_pos, IM_COL32(255, 255, 255, 255), buf); + + if (!s_current_game_serial.empty()) + { + const std::time_t cached_played_time = GameList::GetCachedPlayedTimeForSerial(s_current_game_serial); + const std::time_t session_time = static_cast(CommonHost::GetSessionPlayedTime()); + const std::string played_time_str(GameList::FormatTimespan(cached_played_time + session_time, true)); + const std::string session_time_str(GameList::FormatTimespan(session_time, true)); + + std::snprintf(buf, std::size(buf), "This Session: %s", session_time_str.c_str()); + const ImVec2 session_size(g_medium_font->CalcTextSizeA(g_medium_font->FontSize, std::numeric_limits::max(), -1.0f, buf)); + const ImVec2 session_pos(display_size.x - LayoutScale(10.0f) - session_size.x, time_pos.y + g_large_font->FontSize + LayoutScale(4.0f)); + DrawShadowedText(dl, g_medium_font, session_pos, IM_COL32(255, 255, 255, 255), buf); + + std::snprintf(buf, std::size(buf), "All Time: %s", played_time_str.c_str()); + const ImVec2 total_size(g_medium_font->CalcTextSizeA(g_medium_font->FontSize, std::numeric_limits::max(), -1.0f, buf)); + const ImVec2 total_pos(display_size.x - LayoutScale(10.0f) - total_size.x, session_pos.y + g_medium_font->FontSize + LayoutScale(4.0f)); + DrawShadowedText(dl, g_medium_font, total_pos, IM_COL32(255, 255, 255, 255), buf); + } + } + const ImVec2 window_size(LayoutScale(500.0f, LAYOUT_SCREEN_HEIGHT)); const ImVec2 window_pos(0.0f, display_size.y - window_size.y); @@ -4395,6 +4437,10 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size) } ImGui::Text(" (%s)", GameList::EntryCompatibilityRatingToString(selected_entry->compatibility_rating)); + // play time + ImGui::Text("Time Played: %s", GameList::FormatTimespan(selected_entry->total_played_time).c_str()); + ImGui::Text("Last Played: %s", GameList::FormatTimestamp(selected_entry->last_played_time).c_str()); + // size ImGui::Text("Size: %.2f MB", static_cast(selected_entry->total_size) / 1048576.0f); diff --git a/pcsx2/Frontend/GameList.cpp b/pcsx2/Frontend/GameList.cpp index 9ff9cbdb62..39da569fe5 100644 --- a/pcsx2/Frontend/GameList.cpp +++ b/pcsx2/Frontend/GameList.cpp @@ -20,6 +20,7 @@ #include "common/Assertions.h" #include "common/Console.h" #include "common/FileSystem.h" +#include "common/HeterogeneousContainers.h" #include "common/HTTPDownloader.h" #include "common/Path.h" #include "common/ProgressCallback.h" @@ -37,15 +38,32 @@ #include "Elfheader.h" #include "VMManager.h" -enum : u32 -{ - GAME_LIST_CACHE_SIGNATURE = 0x45434C47, - GAME_LIST_CACHE_VERSION = 32 -}; +#ifdef _WIN32 +#include "common/RedtapeWindows.h" +#endif namespace GameList { - using CacheMap = std::unordered_map; + enum : u32 + { + GAME_LIST_CACHE_SIGNATURE = 0x45434C47, + GAME_LIST_CACHE_VERSION = 32, + + + PLAYED_TIME_SERIAL_LENGTH = 32, + PLAYED_TIME_LAST_TIME_LENGTH = 20, // uint64 + PLAYED_TIME_TOTAL_TIME_LENGTH = 20, // uint64 + PLAYED_TIME_LINE_LENGTH = PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1 + PLAYED_TIME_TOTAL_TIME_LENGTH, + }; + + struct PlayedTimeEntry + { + std::time_t last_played_time; + std::time_t total_played_time; + }; + + using CacheMap = UnorderedStringMap; + using PlayedTimeMap = UnorderedStringMap; static bool IsScannableFilename(const std::string_view& path); @@ -55,10 +73,11 @@ namespace GameList static bool GetIsoListEntry(const std::string& path, GameList::Entry* entry); static bool GetGameListEntryFromCache(const std::string& path, GameList::Entry* entry); - static void ScanDirectory( - const char* path, bool recursive, bool only_cache, const std::vector& excluded_paths, ProgressCallback* progress); - static bool AddFileFromCache(const std::string& path, std::time_t timestamp); - static bool ScanFile(std::string path, std::time_t timestamp); + static void ScanDirectory(const char* path, bool recursive, bool only_cache, const std::vector& excluded_paths, + const PlayedTimeMap& played_time_map, ProgressCallback* progress); + static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map); + static bool ScanFile( + std::string path, std::time_t timestamp, std::unique_lock& lock, const PlayedTimeMap& played_time_map); static void LoadCache(); static bool LoadEntriesFromCache(std::FILE* stream); @@ -67,20 +86,18 @@ namespace GameList static void CloseCacheFileStream(); static void DeleteCacheFile(); - static void LoadDatabase(); + static std::string GetPlayedTimeFile(); + static bool ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry); + static std::string MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry); + static PlayedTimeMap LoadPlayedTimeMap(const std::string& path); + static PlayedTimeEntry UpdatePlayedTimeFile( + const std::string& path, const std::string& serial, std::time_t last_time, std::time_t add_time); } // namespace GameList -static std::vector m_entries; +static std::vector s_entries; static std::recursive_mutex s_mutex; -static GameList::CacheMap m_cache_map; -static std::FILE* m_cache_write_stream = nullptr; - -static bool m_game_list_loaded = false; - -bool GameList::IsGameListLoaded() -{ - return m_game_list_loaded; -} +static GameList::CacheMap s_cache_map; +static std::FILE* s_cache_write_stream = nullptr; const char* GameList::EntryTypeToString(EntryType type) { @@ -308,12 +325,12 @@ bool GameList::PopulateEntryFromPath(const std::string& path, GameList::Entry* e bool GameList::GetGameListEntryFromCache(const std::string& path, GameList::Entry* entry) { - auto iter = m_cache_map.find(path); - if (iter == m_cache_map.end()) + auto iter = UnorderedStringMapFind(s_cache_map, path); + if (iter == s_cache_map.end()) return false; *entry = std::move(iter->second); - m_cache_map.erase(iter); + s_cache_map.erase(iter); return true; } @@ -406,11 +423,11 @@ bool GameList::LoadEntriesFromCache(std::FILE* stream) ge.compatibility_rating = static_cast(compatibility_rating); ge.last_modified_time = static_cast(last_modified_time); - auto iter = m_cache_map.find(ge.path); - if (iter != m_cache_map.end()) + auto iter = UnorderedStringMapFind(s_cache_map, ge.path); + if (iter != s_cache_map.end()) iter->second = std::move(ge); else - m_cache_map.emplace(std::move(path), std::move(ge)); + s_cache_map.emplace(std::move(path), std::move(ge)); } return true; @@ -432,7 +449,7 @@ void GameList::LoadCache() { Console.Warning("Deleting corrupted cache file '%s'", cache_filename.c_str()); stream.reset(); - m_cache_map.clear(); + s_cache_map.clear(); DeleteCacheFile(); return; } @@ -444,36 +461,36 @@ bool GameList::OpenCacheForWriting() if (cache_filename.empty()) return false; - pxAssert(!m_cache_write_stream); - m_cache_write_stream = FileSystem::OpenCFile(cache_filename.c_str(), "r+b"); - if (m_cache_write_stream) + pxAssert(!s_cache_write_stream); + s_cache_write_stream = FileSystem::OpenCFile(cache_filename.c_str(), "r+b"); + if (s_cache_write_stream) { // check the header u32 signature, version; - if (ReadU32(m_cache_write_stream, &signature) && signature == GAME_LIST_CACHE_SIGNATURE && - ReadU32(m_cache_write_stream, &version) && version == GAME_LIST_CACHE_VERSION && - FileSystem::FSeek64(m_cache_write_stream, 0, SEEK_END) == 0) + if (ReadU32(s_cache_write_stream, &signature) && signature == GAME_LIST_CACHE_SIGNATURE && + ReadU32(s_cache_write_stream, &version) && version == GAME_LIST_CACHE_VERSION && + FileSystem::FSeek64(s_cache_write_stream, 0, SEEK_END) == 0) { return true; } - std::fclose(m_cache_write_stream); + std::fclose(s_cache_write_stream); } Console.WriteLn("Creating new game list cache file: '%s'", cache_filename.c_str()); - m_cache_write_stream = FileSystem::OpenCFile(cache_filename.c_str(), "w+b"); - if (!m_cache_write_stream) + s_cache_write_stream = FileSystem::OpenCFile(cache_filename.c_str(), "w+b"); + if (!s_cache_write_stream) return false; // new cache file, write header - if (!WriteU32(m_cache_write_stream, GAME_LIST_CACHE_SIGNATURE) || - !WriteU32(m_cache_write_stream, GAME_LIST_CACHE_VERSION)) + if (!WriteU32(s_cache_write_stream, GAME_LIST_CACHE_SIGNATURE) || + !WriteU32(s_cache_write_stream, GAME_LIST_CACHE_VERSION)) { Console.Error("Failed to write game list cache header"); - std::fclose(m_cache_write_stream); - m_cache_write_stream = nullptr; + std::fclose(s_cache_write_stream); + s_cache_write_stream = nullptr; FileSystem::DeleteFilePath(cache_filename.c_str()); return false; } @@ -484,35 +501,35 @@ bool GameList::OpenCacheForWriting() bool GameList::WriteEntryToCache(const Entry* entry) { bool result = true; - result &= WriteString(m_cache_write_stream, entry->path); - result &= WriteString(m_cache_write_stream, entry->serial); - result &= WriteString(m_cache_write_stream, entry->title); - result &= WriteU8(m_cache_write_stream, static_cast(entry->type)); - result &= WriteU8(m_cache_write_stream, static_cast(entry->region)); - result &= WriteU64(m_cache_write_stream, entry->total_size); - result &= WriteU64(m_cache_write_stream, static_cast(entry->last_modified_time)); - result &= WriteU32(m_cache_write_stream, entry->crc); - result &= WriteU8(m_cache_write_stream, static_cast(entry->compatibility_rating)); + result &= WriteString(s_cache_write_stream, entry->path); + result &= WriteString(s_cache_write_stream, entry->serial); + result &= WriteString(s_cache_write_stream, entry->title); + result &= WriteU8(s_cache_write_stream, static_cast(entry->type)); + result &= WriteU8(s_cache_write_stream, static_cast(entry->region)); + result &= WriteU64(s_cache_write_stream, entry->total_size); + result &= WriteU64(s_cache_write_stream, static_cast(entry->last_modified_time)); + result &= WriteU32(s_cache_write_stream, entry->crc); + result &= WriteU8(s_cache_write_stream, static_cast(entry->compatibility_rating)); // flush after each entry, that way we don't end up with a corrupted file if we crash scanning. if (result) - result = (std::fflush(m_cache_write_stream) == 0); + result = (std::fflush(s_cache_write_stream) == 0); return result; } void GameList::CloseCacheFileStream() { - if (!m_cache_write_stream) + if (!s_cache_write_stream) return; - std::fclose(m_cache_write_stream); - m_cache_write_stream = nullptr; + std::fclose(s_cache_write_stream); + s_cache_write_stream = nullptr; } void GameList::DeleteCacheFile() { - pxAssert(!m_cache_write_stream); + pxAssert(!s_cache_write_stream); const std::string cache_filename(GetCacheFilename()); if (cache_filename.empty() || !FileSystem::FileExists(cache_filename.c_str())) @@ -529,7 +546,8 @@ static bool IsPathExcluded(const std::vector& excluded_paths, const return (std::find(excluded_paths.begin(), excluded_paths.end(), path) != excluded_paths.end()); } -void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, const std::vector& excluded_paths, ProgressCallback* progress) +void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, const std::vector& excluded_paths, const PlayedTimeMap& played_time_map, + ProgressCallback* progress) { Console.WriteLn("Scanning %s%s", path, recursive ? " (recursively)" : ""); @@ -556,19 +574,15 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, continue; } + std::unique_lock lock(s_mutex); + if (GetEntryForPath(ffd.FileName.c_str()) || + AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map) || only_cache) { - std::unique_lock lock(s_mutex); - if (GetEntryForPath(ffd.FileName.c_str()) || - AddFileFromCache(ffd.FileName, ffd.ModificationTime) || - only_cache) - { - continue; - } + continue; } - // ownership of fp is transferred progress->SetFormattedStatusText("Scanning '%s'...", FileSystem::GetDisplayNameFromPath(ffd.FileName).c_str()); - ScanFile(std::move(ffd.FileName), ffd.ModificationTime); + ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map); progress->SetProgressValue(files_scanned); } @@ -576,24 +590,29 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, progress->PopState(); } -bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp) +bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map) { - if (std::any_of(m_entries.begin(), m_entries.end(), [&path](const Entry& other) { return other.path == path; })) - { - // already exists - return true; - } - Entry entry; if (!GetGameListEntryFromCache(path, &entry) || entry.last_modified_time != timestamp) return false; - m_entries.push_back(std::move(entry)); + auto iter = UnorderedStringMapFind(played_time_map, entry.serial); + if (iter != played_time_map.end()) + { + entry.last_played_time = iter->second.last_played_time; + entry.total_played_time = iter->second.total_played_time; + } + + s_entries.push_back(std::move(entry)); return true; } -bool GameList::ScanFile(std::string path, std::time_t timestamp) +bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_lock& lock, + const PlayedTimeMap& played_time_map) { + // don't block UI while scanning + lock.unlock(); + DevCon.WriteLn("Scanning '%s'...", path.c_str()); Entry entry; @@ -603,14 +622,21 @@ bool GameList::ScanFile(std::string path, std::time_t timestamp) entry.path = std::move(path); entry.last_modified_time = timestamp; - if (m_cache_write_stream || OpenCacheForWriting()) + if (s_cache_write_stream || OpenCacheForWriting()) { if (!WriteEntryToCache(&entry)) Console.Warning("Failed to write entry '%s' to cache", entry.path.c_str()); } - std::unique_lock lock(s_mutex); - m_entries.push_back(std::move(entry)); + auto iter = UnorderedStringMapFind(played_time_map, entry.serial); + if (iter != played_time_map.end()) + { + entry.last_played_time = iter->second.last_played_time; + entry.total_played_time = iter->second.total_played_time; + } + + lock.lock(); + s_entries.push_back(std::move(entry)); return true; } @@ -621,13 +647,13 @@ std::unique_lock GameList::GetLock() const GameList::Entry* GameList::GetEntryByIndex(u32 index) { - return (index < m_entries.size()) ? &m_entries[index] : nullptr; + return (index < s_entries.size()) ? &s_entries[index] : nullptr; } const GameList::Entry* GameList::GetEntryForPath(const char* path) { const size_t path_length = std::strlen(path); - for (const Entry& entry : m_entries) + for (const Entry& entry : s_entries) { if (entry.path.size() == path_length && StringUtil::Strcasecmp(entry.path.c_str(), path) == 0) return &entry; @@ -638,7 +664,7 @@ const GameList::Entry* GameList::GetEntryForPath(const char* path) const GameList::Entry* GameList::GetEntryByCRC(u32 crc) { - for (const Entry& entry : m_entries) + for (const Entry& entry : s_entries) { if (entry.crc == crc) return &entry; @@ -649,7 +675,7 @@ const GameList::Entry* GameList::GetEntryByCRC(u32 crc) const GameList::Entry* GameList::GetEntryBySerialAndCRC(const std::string_view& serial, u32 crc) { - for (const Entry& entry : m_entries) + for (const Entry& entry : s_entries) { if (entry.crc == crc && StringUtil::compareNoCase(entry.serial, serial)) return &entry; @@ -661,7 +687,7 @@ const GameList::Entry* GameList::GetEntryBySerialAndCRC(const std::string_view& GameList::Entry* GameList::GetMutableEntryForPath(const char* path) { const size_t path_length = std::strlen(path); - for (Entry& entry : m_entries) + for (Entry& entry : s_entries) { if (entry.path.size() == path_length && StringUtil::Strcasecmp(entry.path.c_str(), path) == 0) return &entry; @@ -672,13 +698,11 @@ GameList::Entry* GameList::GetMutableEntryForPath(const char* path) u32 GameList::GetEntryCount() { - return static_cast(m_entries.size()); + return static_cast(s_entries.size()); } void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* progress /* = nullptr */) { - m_game_list_loaded = true; - if (!progress) progress = ProgressCallback::NullProgressCallback; @@ -691,12 +715,13 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* std::vector old_entries; { std::unique_lock lock(s_mutex); - old_entries.swap(m_entries); + old_entries.swap(s_entries); } - const std::vector excluded_paths(Host::GetStringListSetting("GameList", "ExcludedPaths")); - const std::vector dirs(Host::GetStringListSetting("GameList", "Paths")); - const std::vector recursive_dirs(Host::GetStringListSetting("GameList", "RecursivePaths")); + const std::vector excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths")); + const std::vector dirs(Host::GetBaseStringListSetting("GameList", "Paths")); + const std::vector recursive_dirs(Host::GetBaseStringListSetting("GameList", "RecursivePaths")); + const PlayedTimeMap played_time(LoadPlayedTimeMap(GetPlayedTimeFile())); if (!dirs.empty() || !recursive_dirs.empty()) { @@ -710,7 +735,7 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* if (progress->IsCancelled()) break; - ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, progress); + ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, progress); progress->SetProgressValue(++directory_counter); } for (const std::string& dir : recursive_dirs) @@ -718,14 +743,277 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* if (progress->IsCancelled()) break; - ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, progress); + ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, progress); progress->SetProgressValue(++directory_counter); } } // don't need unused cache entries CloseCacheFileStream(); - m_cache_map.clear(); + s_cache_map.clear(); +} + +std::string GameList::GetPlayedTimeFile() +{ + return Path::Combine(EmuFolders::Settings, "playtime.dat"); +} + +bool GameList::ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry) +{ + size_t len = std::strlen(line); + if (len != (PLAYED_TIME_LINE_LENGTH + 1)) // \n + { + Console.Warning("(ParsePlayedTimeLine) Malformed line: '%s'", line); + return false; + } + + const std::string_view serial_tok(StringUtil::StripWhitespace(std::string_view(line, PLAYED_TIME_SERIAL_LENGTH))); + const std::string_view total_played_time_tok( + StringUtil::StripWhitespace(std::string_view(line + PLAYED_TIME_SERIAL_LENGTH + 1, PLAYED_TIME_LAST_TIME_LENGTH))); + const std::string_view last_played_time_tok(StringUtil::StripWhitespace(std::string_view( + line + PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1, PLAYED_TIME_TOTAL_TIME_LENGTH))); + + const std::optional total_played_time(StringUtil::FromChars(total_played_time_tok)); + const std::optional last_played_time(StringUtil::FromChars(last_played_time_tok)); + if (serial_tok.empty() || !last_played_time.has_value() || !total_played_time.has_value()) + { + Console.Warning("(ParsePlayedTimeLine) Malformed line: '%s'", line); + return false; + } + + serial = serial_tok; + entry.last_played_time = static_cast(last_played_time.value()); + entry.total_played_time = static_cast(total_played_time.value()); + return true; +} + +std::string GameList::MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry) +{ + return fmt::format("{:<{}} {:<{}} {:<{}}\n", serial, static_cast(PLAYED_TIME_SERIAL_LENGTH), + entry.total_played_time, static_cast(PLAYED_TIME_TOTAL_TIME_LENGTH), + entry.last_played_time, static_cast(PLAYED_TIME_LAST_TIME_LENGTH)); +} + +GameList::PlayedTimeMap GameList::LoadPlayedTimeMap(const std::string& path) +{ + PlayedTimeMap ret; + + // Use write mode here, even though we're not writing, so we can lock the file from other updates. + auto fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + +#ifdef _WIN32 + // On Windows, the file is implicitly locked. + while (!fp && GetLastError() == ERROR_SHARING_VIOLATION) + { + Sleep(10); + fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + } +#endif + + if (fp) + { +#ifndef _WIN32 + FileSystem::POSIXLock flock(fp.get()); +#endif + + char line[256]; + while (std::fgets(line, sizeof(line), fp.get())) + { + std::string serial; + PlayedTimeEntry entry; + if (!ParsePlayedTimeLine(line, serial, entry)) + continue; + + if (UnorderedStringMapFind(ret, serial) != ret.end()) + { + Console.Warning("(LoadPlayedTimeMap) Duplicate entry: '%s'", serial.c_str()); + continue; + } + + ret.emplace(std::move(serial), entry); + } + } + + return ret; +} + +GameList::PlayedTimeEntry GameList::UpdatePlayedTimeFile(const std::string& path, const std::string& serial, + std::time_t last_time, std::time_t add_time) +{ + const PlayedTimeEntry new_entry{ last_time, add_time }; + + auto fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + +#ifdef _WIN32 + // On Windows, the file is implicitly locked. + while (!fp && GetLastError() == ERROR_SHARING_VIOLATION) + { + Sleep(10); + fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + } +#endif + + // Doesn't exist? Create it. + if (!fp && errno == ENOENT) + fp = FileSystem::OpenManagedCFile(path.c_str(), "w+b"); + + if (!fp) + { + Console.Error("Failed to open '%s' for update.", path.c_str()); + return new_entry; + } + +#ifndef _WIN32 + FileSystem::POSIXLock flock(fp.get()); +#endif + + for (;;) + { + char line[256]; + const s64 line_pos = FileSystem::FTell64(fp.get()); + if (!std::fgets(line, sizeof(line), fp.get())) + break; + + std::string line_serial; + PlayedTimeEntry line_entry; + if (!ParsePlayedTimeLine(line, line_serial, line_entry)) + continue; + + if (line_serial != serial) + continue; + + // found it! + line_entry.last_played_time = last_time; + line_entry.total_played_time += add_time; + + std::string new_line(MakePlayedTimeLine(serial, line_entry)); + if (FileSystem::FSeek64(fp.get(), line_pos, SEEK_SET) != 0 || + std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1 || + std::fflush(fp.get()) != 0) + { + Console.Error("Failed to update '%s'.", path.c_str()); + } + + return line_entry; + } + + // new entry. + std::string new_line(MakePlayedTimeLine(serial, new_entry)); + if (FileSystem::FSeek64(fp.get(), 0, SEEK_END) != 0 || + std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1) + { + Console.Error("Failed to write '%s'.", path.c_str()); + } + + return new_entry; +} + +void GameList::AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time) +{ + if (serial.empty()) + return; + + const PlayedTimeEntry pt(UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, last_time, add_time)); + Console.WriteLn("Add %u seconds play time to %s -> now %u", static_cast(add_time), serial.c_str(), + static_cast(pt.total_played_time)); + + std::unique_lock lock(s_mutex); + for (GameList::Entry& entry : s_entries) + { + if (entry.serial != serial) + continue; + + entry.last_played_time = pt.last_played_time; + entry.total_played_time = pt.total_played_time; + } +} + +std::time_t GameList::GetCachedPlayedTimeForSerial(const std::string& serial) +{ + if (serial.empty()) + return 0; + + std::unique_lock lock(s_mutex); + for (GameList::Entry& entry : s_entries) + { + if (entry.serial == serial) + return entry.total_played_time; + } + + return 0; +} + +std::string GameList::FormatTimestamp(std::time_t timestamp) +{ + // TODO: All these strings should be translateable. + std::string ret; + + if (timestamp == 0) + { + ret = "Never"; + } + else + { + struct tm ctime = {}; + struct tm ttime = {}; + const std::time_t ctimestamp = std::time(nullptr); +#ifdef _MSC_VER + localtime_s(&ctime, &ctimestamp); + localtime_s(&ttime, ×tamp); +#else + localtime_r(&ctimestamp, &ctime); + localtime_r(×tamp, &ttime); +#endif + + if (ctime.tm_year == ttime.tm_year && ctime.tm_yday == ttime.tm_yday) + { + ret = "Today"; + } + else if ((ctime.tm_year == ttime.tm_year && ctime.tm_yday == (ttime.tm_yday + 1)) || + (ctime.tm_yday == 0 && (ctime.tm_year - 1) == ttime.tm_year)) + { + ret = "Yesterday"; + } + else + { + char buf[128]; + std::strftime(buf, std::size(buf), "%x", &ttime); + ret.assign(buf); + } + } + + return ret; +} + +std::string GameList::FormatTimespan(std::time_t timespan, bool long_format) +{ + const u32 hours = static_cast(timespan / 3600); + const u32 minutes = static_cast((timespan % 3600) / 60); + const u32 seconds = static_cast((timespan % 3600) % 60); + + std::string ret; + if (!long_format) + { + if (hours >= 100) + ret = fmt::format("{}h {}m", hours, minutes); + else if (hours > 0) + ret = fmt::format("{}h {}m {}s", hours, minutes, seconds); + else if (minutes > 0) + ret = fmt::format("{}m {}s", minutes, seconds); + else if (seconds > 0) + ret = fmt::format("{}s", seconds); + else + ret = "None"; + } + else + { + if (hours > 0) + ret = fmt::format("{} hours", hours); + else + ret = fmt::format("{} minutes", minutes); + } + + return ret; } std::string GameList::GetCoverImagePathForEntry(const Entry* entry) @@ -825,7 +1113,7 @@ bool GameList::DownloadCovers(const std::vector& url_templates, boo std::vector> download_urls; { std::unique_lock lock(s_mutex); - for (const GameList::Entry& entry : m_entries) + for (const GameList::Entry& entry : s_entries) { const std::string existing_path(GetCoverImagePathForEntry(&entry)); if (!existing_path.empty()) diff --git a/pcsx2/Frontend/GameList.h b/pcsx2/Frontend/GameList.h index 41fc76be9d..91a6795784 100644 --- a/pcsx2/Frontend/GameList.h +++ b/pcsx2/Frontend/GameList.h @@ -87,6 +87,8 @@ namespace GameList std::string title; u64 total_size = 0; std::time_t last_modified_time = 0; + std::time_t last_played_time = 0; + std::time_t total_played_time = 0; u32 crc = 0; @@ -115,13 +117,23 @@ namespace GameList const Entry* GetEntryBySerialAndCRC(const std::string_view& serial, u32 crc); u32 GetEntryCount(); - bool IsGameListLoaded(); - /// Populates the game list with files in the configured directories. /// If invalidate_cache is set, all files will be re-scanned. /// If only_cache is set, no new files will be scanned, only those present in the cache. void Refresh(bool invalidate_cache, bool only_cache = false, ProgressCallback* progress = nullptr); + /// Add played time for the specified serial. + void AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time); + + /// Returns the total time played for a game. Requires the game to be scanned in the list. + std::time_t GetCachedPlayedTimeForSerial(const std::string& serial); + + /// Formats a timestamp to something human readable (e.g. Today, Yesterday, 10/11/12). + std::string FormatTimestamp(std::time_t timestamp); + + /// Formats a timespan to something human readable (e.g. 1h2m3s or 1 hour). + std::string FormatTimespan(std::time_t timespan, bool long_format = false); + std::string GetCoverImagePathForEntry(const Entry* entry); std::string GetCoverImagePath(const std::string& path, const std::string& code, const std::string& title); std::string GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial = false); diff --git a/pcsx2/Frontend/ImGuiOverlays.cpp b/pcsx2/Frontend/ImGuiOverlays.cpp index d4e71f4bcd..92cc525537 100644 --- a/pcsx2/Frontend/ImGuiOverlays.cpp +++ b/pcsx2/Frontend/ImGuiOverlays.cpp @@ -100,8 +100,10 @@ void ImGuiManager::DrawPerformanceOverlay() #ifdef PCSX2_CORE const bool paused = (VMManager::GetState() == VMState::Paused); + const bool fsui_active = FullscreenUI::HasActiveWindow(); #else - constexpr bool paused = false; + const bool paused = false; + const bool fsui_active = false; #endif if (!paused) @@ -221,7 +223,7 @@ void ImGuiManager::DrawPerformanceOverlay() } } } - else + else if (!fsui_active) { if (GSConfig.OsdShowIndicators) {