// Copyright (C) 2024-2026 NXST contributors #include #include #include #include "nxst/domain/account.hpp" #include #include #include #include #include using sort_t = enum { SortAlpha, SortLastPlayed, SortPlayTime, SortModesCount }; static constexpr const char* kEmptySave = "New..."; static sort_t s_sort_mode = SortAlpha; static std::unordered_map> titles; static bool s_titles_loaded = false; void Title::init(u8 save_data_type, u64 title_id, AccountUid uid, const std::string& name, const std::string& author) { m_id = title_id; m_uid = uid; m_save_data_type = save_data_type; m_user_name = Account::username(uid); m_author = author; m_name = name; m_safe_name = StringUtils::containsInvalidChar(name) ? StringUtils::format("0x%016llX", m_id) : StringUtils::removeForbiddenCharacters(name); m_path = "sdmc:/switch/NXST/saves/" + StringUtils::format("0x%016llX", m_id) + " " + m_safe_name; std::string aname = StringUtils::removeAccents(m_name); m_display_name = {aname, ""}; size_t colon = aname.rfind(':'); if (colon != std::string::npos) { std::string head = aname.substr(0, colon); std::string tail = aname.substr(colon + 1); StringUtils::trim(head); StringUtils::trim(tail); m_display_name = {head, tail}; } else { size_t open = aname.rfind('('); size_t close = aname.rfind(')'); if (open != std::string::npos && close != std::string::npos && close > open) { std::string head = aname.substr(0, open); std::string paren = aname.substr(open + 1, close - open - 1); StringUtils::trim(head); StringUtils::trim(paren); m_display_name = {head, paren}; } } refreshDirectories(); } u8 Title::saveDataType() const { return m_save_data_type; } u64 Title::id() const { return m_id; } u64 Title::saveId() const { return m_save_id; } void Title::saveId(u64 id) { m_save_id = id; } AccountUid Title::userId() const { return m_uid; } std::string Title::userName() const { return m_user_name; } std::string Title::author() const { return m_author; } std::string Title::name() const { return m_name; } std::pair Title::displayName() const { return m_display_name; } std::string Title::path() const { return m_path; } std::string Title::fullPath(size_t index) const { return m_full_save_paths.at(index); } std::vector Title::saves() const { return m_saves; } u64 Title::playTimeNanoseconds() const { return m_play_time_ns; } void Title::playTimeNanoseconds(u64 ns) { m_play_time_ns = ns; } u32 Title::lastPlayedTimestamp() const { return m_last_played_ts; } void Title::lastPlayedTimestamp(u32 ts) { m_last_played_ts = ts; } std::string Title::playTime() const { const u64 minutes = m_play_time_ns / 60000000000ULL; return StringUtils::format("%d", minutes / 60) + ":" + StringUtils::format("%02d", minutes % 60) + " hours"; } void Title::refreshDirectories() { m_saves.clear(); m_full_save_paths.clear(); Directory savelist(m_path); if (savelist.good()) { for (size_t i = 0; i < savelist.size(); ++i) { if (savelist.folder(i)) { m_saves.push_back(savelist.entry(i)); m_full_save_paths.push_back(m_path + "/" + savelist.entry(i)); } } std::sort(m_saves.rbegin(), m_saves.rend()); std::sort(m_full_save_paths.rbegin(), m_full_save_paths.rend()); m_saves.insert(m_saves.begin(), kEmptySave); m_full_save_paths.insert(m_full_save_paths.begin(), kEmptySave); } else { nxst::log::error("Could not read save directory for title %s", m_name.c_str()); } } bool areTitlesLoaded() { return s_titles_loaded; } void loadTitles() { if (s_titles_loaded) return; s_titles_loaded = true; titles.clear(); FsSaveDataInfoReader reader; Result res = fsOpenSaveDataInfoReader(&reader, FsSaveDataSpaceId_User); if (R_FAILED(res)) return; std::vector nacp_buf(sizeof(NsApplicationControlData), 0); auto* nsacd = reinterpret_cast(nacp_buf.data()); FsSaveDataInfo info{}; s64 count = 0; while (true) { res = fsSaveDataInfoReaderRead(&reader, &info, 1, &count); if (R_FAILED(res) || count == 0) break; if (info.save_data_type != FsSaveDataType_Account) continue; u64 tid = info.application_id; AccountUid uid = info.uid; size_t outsize = 0; NacpLanguageEntry* nle = nullptr; memset(nsacd, 0, sizeof(NsApplicationControlData)); res = nsGetApplicationControlData(NsApplicationControlSource_Storage, tid, nsacd, sizeof(NsApplicationControlData), &outsize); if (R_FAILED(res) || outsize < sizeof(nsacd->nacp)) continue; if (R_FAILED(nacpGetLanguageEntry(&nsacd->nacp, &nle)) || !nle) continue; Title title; title.init(info.save_data_type, tid, uid, std::string(nle->name), std::string(nle->author)); title.saveId(info.save_data_id); PdmPlayStatistics stats{}; if (R_SUCCEEDED(pdmqryQueryPlayStatisticsByApplicationIdAndUserAccountId(tid, uid, false, &stats))) { title.playTimeNanoseconds(stats.playtime); title.lastPlayedTimestamp(stats.last_timestamp_user); } auto it = titles.find(uid); if (it != titles.end()) { it->second.push_back(title); } else { titles.emplace(uid, std::vector{title}); } } fsSaveDataInfoReaderClose(&reader); sortTitles(); } void sortTitles() { for (auto& pair : titles) { std::sort(pair.second.begin(), pair.second.end(), [](const Title& l, const Title& r) { switch (s_sort_mode) { case SortLastPlayed: return l.lastPlayedTimestamp() > r.lastPlayedTimestamp(); case SortPlayTime: return l.playTimeNanoseconds() > r.playTimeNanoseconds(); case SortAlpha: default: return l.name() < r.name(); } }); } } void rotateSortMode() { s_sort_mode = static_cast<sort_t>((s_sort_mode + 1) % SortModesCount); sortTitles(); } void getTitle(Title& dst, AccountUid uid, size_t i) { auto it = titles.find(uid); if (it != titles.end() && i < it->second.size()) dst = it->second[i]; } size_t getTitleCount(AccountUid uid) { auto it = titles.find(uid); return it != titles.end() ? it->second.size() : 0; } void refreshDirectories(u64 id) { for (auto& pair : titles) { for (auto& title : pair.second) { if (title.id() == id) title.refreshDirectories(); } } } std::unordered_map<std::string, std::string> getCompleteTitleList() { std::unordered_map<std::string, std::string> map; for (const auto& pair : titles) { for (const auto& title : pair.second) { map.emplace(StringUtils::format("0x%016llX", title.id()), title.name()); } } return map; }