#include "TitleList.h" #include "Common/FileStream.h" #include "util/helpers/helpers.h" #include bool sTLInitialized{ false }; fs::path sTLCacheFilePath; // lists for tracking known titles // note: The list may only contain titles with valid meta data (except for certain system titles). Entries loaded from the cache may not have been parsed yet, but they will use a cached value for titleId and titleVersion std::mutex sTLMutex; std::vector sTLList; std::vector sTLListPending; std::unordered_multimap sTLMap; bool sTLCacheDirty{false}; // paths fs::path sTLMLCPath; std::vector sTLScanPaths; // worker std::thread sTLRefreshWorker; bool sTLRefreshWorkerActive{false}; std::atomic_uint32_t sTLRefreshRequests{}; std::atomic_bool sTLIsScanMandatory{ false }; // callback list struct TitleListCallbackEntry { TitleListCallbackEntry(void(*cb)(CafeTitleListCallbackEvent* evt, void* ctx), void* ctx, uint64 uniqueId) : cb(cb), ctx(ctx), uniqueId(uniqueId) {}; void (*cb)(CafeTitleListCallbackEvent* evt, void* ctx); void* ctx; uint64 uniqueId; }; std::vector sTLCallbackList; void CafeTitleList::Initialize(const fs::path cacheXmlFile) { std::unique_lock _lock(sTLMutex); sTLInitialized = true; sTLCacheFilePath = cacheXmlFile; LoadCacheFile(); } void CafeTitleList::LoadCacheFile() { sTLIsScanMandatory = true; cemu_assert_debug(sTLInitialized); cemu_assert_debug(sTLList.empty()); auto xmlData = FileStream::LoadIntoMemory(sTLCacheFilePath); if (!xmlData) return; pugi::xml_document doc; if (!doc.load_buffer_inplace(xmlData->data(), xmlData->size(), pugi::parse_default, pugi::xml_encoding::encoding_utf8)) return; auto titleListNode = doc.child("title_list"); pugi::xml_node itNode = titleListNode.first_child(); for (const auto& titleInfoNode : doc.child("title_list")) { TitleId titleId; if( !TitleIdParser::ParseFromStr(titleInfoNode.attribute("titleId").as_string(), titleId)) continue; uint16 titleVersion = titleInfoNode.attribute("version").as_uint(); uint32 sdkVersion = titleInfoNode.attribute("sdk_version").as_uint(); TitleInfo::TitleDataFormat format = (TitleInfo::TitleDataFormat)ConvertString(titleInfoNode.child_value("format")); CafeConsoleRegion region = (CafeConsoleRegion)ConvertString(titleInfoNode.child_value("region")); std::string name = titleInfoNode.child_value("name"); std::string path = titleInfoNode.child_value("path"); std::string sub_path = titleInfoNode.child_value("sub_path"); uint32 group_id = ConvertString(titleInfoNode.attribute("group_id").as_string(), 16); uint32 app_type = ConvertString(titleInfoNode.attribute("app_type").as_string(), 16); TitleInfo::CachedInfo cacheEntry; cacheEntry.titleId = titleId; cacheEntry.titleVersion = titleVersion; cacheEntry.sdkVersion = sdkVersion; cacheEntry.titleDataFormat = format; cacheEntry.region = region; cacheEntry.titleName = std::move(name); cacheEntry.path = _utf8ToPath(path); cacheEntry.subPath = std::move(sub_path); cacheEntry.group_id = group_id; cacheEntry.app_type = app_type; TitleInfo* ti = new TitleInfo(cacheEntry); if (!ti->IsValid()) { cemuLog_log(LogType::Force, "Title cache contained invalid title"); delete ti; continue; } AddTitle(ti); } sTLIsScanMandatory = false; } void CafeTitleList::StoreCacheFile() { cemu_assert_debug(sTLInitialized); if (sTLCacheFilePath.empty()) return; std::unique_lock _lock(sTLMutex); pugi::xml_document doc; auto declarationNode = doc.append_child(pugi::node_declaration); declarationNode.append_attribute("version") = "1.0"; declarationNode.append_attribute("encoding") = "UTF-8"; auto title_list_node = doc.append_child("title_list"); for (auto& tiIt : sTLList) { TitleInfo::CachedInfo info = tiIt->MakeCacheEntry(); auto titleInfoNode = title_list_node.append_child("title"); titleInfoNode.append_attribute("titleId").set_value(fmt::format("{:016x}", info.titleId).c_str()); titleInfoNode.append_attribute("version").set_value(fmt::format("{:}", info.titleVersion).c_str()); titleInfoNode.append_attribute("sdk_version").set_value(fmt::format("{:}", info.sdkVersion).c_str()); titleInfoNode.append_attribute("group_id").set_value(fmt::format("{:08x}", info.group_id).c_str()); titleInfoNode.append_attribute("app_type").set_value(fmt::format("{:08x}", info.app_type).c_str()); titleInfoNode.append_child("region").append_child(pugi::node_pcdata).set_value(fmt::format("{}", (uint32)info.region).c_str()); titleInfoNode.append_child("name").append_child(pugi::node_pcdata).set_value(info.titleName.c_str()); titleInfoNode.append_child("format").append_child(pugi::node_pcdata).set_value(fmt::format("{}", (uint32)info.titleDataFormat).c_str()); titleInfoNode.append_child("path").append_child(pugi::node_pcdata).set_value(_pathToUtf8(info.path).c_str()); if(!info.subPath.empty()) titleInfoNode.append_child("sub_path").append_child(pugi::node_pcdata).set_value(_pathToUtf8(info.subPath).c_str()); } fs::path tmpPath = fs::path(sTLCacheFilePath.parent_path()).append(fmt::format("{}__tmp", _pathToUtf8(sTLCacheFilePath.filename()))); std::ofstream fileOut(tmpPath, std::ios::out | std::ios::binary | std::ios::trunc); if (!fileOut.is_open()) { cemuLog_log(LogType::Force, "Unable to store title list in {}", _pathToUtf8(tmpPath)); return; } doc.save(fileOut, " ", 1, pugi::xml_encoding::encoding_utf8); fileOut.flush(); fileOut.close(); std::error_code ec; fs::rename(tmpPath, sTLCacheFilePath, ec); } void CafeTitleList::ClearScanPaths() { std::unique_lock _lock(sTLMutex); sTLScanPaths.clear(); } void CafeTitleList::AddScanPath(fs::path path) { std::unique_lock _lock(sTLMutex); sTLScanPaths.emplace_back(path); } void CafeTitleList::SetMLCPath(fs::path path) { std::unique_lock _lock(sTLMutex); std::error_code ec; if (!fs::is_directory(path, ec)) { cemuLog_log(LogType::Force, "MLC set to invalid path: {}", _pathToUtf8(path)); return; } sTLMLCPath = path; } void CafeTitleList::Refresh() { std::unique_lock _lock(sTLMutex); cemu_assert_debug(sTLInitialized); sTLRefreshRequests++; if (!sTLRefreshWorkerActive) { if (sTLRefreshWorker.joinable()) sTLRefreshWorker.join(); sTLRefreshWorkerActive = true; sTLRefreshWorker = std::thread(RefreshWorkerThread); } sTLIsScanMandatory = false; } bool CafeTitleList::IsScanning() { std::unique_lock _lock(sTLMutex); return sTLRefreshWorkerActive; } void CafeTitleList::WaitForMandatoryScan() { if (!sTLIsScanMandatory) return; while (IsScanning()) std::this_thread::sleep_for(std::chrono::milliseconds(100)); } void _RemoveTitleFromMultimap(TitleInfo* titleInfo) { auto mapRange = sTLMap.equal_range(titleInfo->GetAppTitleId()); for (auto mapIt = mapRange.first; mapIt != mapRange.second; ++mapIt) { if (mapIt->second == titleInfo) { sTLMap.erase(mapIt); return; } } cemu_assert_suspicious(); } // check if path is a valid title and if it is, permanently add it to the title list // in the special case that path points to a WUA file, all contained titles will be added void CafeTitleList::AddTitleFromPath(fs::path path) { if (path.has_extension() && boost::iequals(_pathToUtf8(path.extension()), ".wua")) { ZArchiveReader* zar = ZArchiveReader::OpenFromFile(path); if (!zar) { cemuLog_log(LogType::Force, "Found {} but it is not a valid Wii U archive file", _pathToUtf8(path)); return; } // enumerate all contained titles ZArchiveNodeHandle rootDir = zar->LookUp("", false, true); cemu_assert(rootDir != ZARCHIVE_INVALID_NODE); for (uint32 i = 0; i < zar->GetDirEntryCount(rootDir); i++) { ZArchiveReader::DirEntry dirEntry; if( !zar->GetDirEntry(rootDir, i, dirEntry) ) continue; if(!dirEntry.isDirectory) continue; TitleId parsedId; uint16 parsedVersion; if (!TitleInfo::ParseWuaTitleFolderName(dirEntry.name, parsedId, parsedVersion)) { cemuLog_log(LogType::Force, "Invalid title directory in {}: \"{}\"", _pathToUtf8(path), dirEntry.name); continue; } // valid subdirectory TitleInfo* titleInfo = new TitleInfo(path, dirEntry.name); if (titleInfo->IsValid()) AddDiscoveredTitle(titleInfo); else delete titleInfo; } delete zar; return; } TitleInfo* titleInfo = new TitleInfo(path); if (titleInfo->IsValid()) AddDiscoveredTitle(titleInfo); else delete titleInfo; } bool CafeTitleList::RefreshWorkerThread() { SetThreadName("TitleListWorker"); while (sTLRefreshRequests.load()) { sTLRefreshRequests.store(0); // create copies of all the paths sTLMutex.lock(); fs::path mlcPath = sTLMLCPath; std::vector gamePaths = sTLScanPaths; // remember the current list of known titles // during the scanning process we will erase matches from the pending list // at the end of scanning, we can then use this list to identify and remove any titles that are no longer discoverable sTLListPending = sTLList; sTLMutex.unlock(); // scan game paths for (auto& it : gamePaths) ScanGamePath(it); // scan MLC if (!mlcPath.empty()) { std::error_code ec; for (auto& it : fs::directory_iterator(mlcPath / "usr/title", ec)) { if (!it.is_directory(ec)) continue; ScanMLCPath(it.path()); } ScanMLCPath(mlcPath / "sys/title/00050010"); ScanMLCPath(mlcPath / "sys/title/00050030"); } // remove any titles that are still pending for (auto& itPending : sTLListPending) { _RemoveTitleFromMultimap(itPending); std::erase(sTLList, itPending); } // send notifications for removed titles, but only if there exists no other title with the same titleId and version if (!sTLListPending.empty()) sTLCacheDirty = true; for (auto& itPending : sTLListPending) { CafeTitleListCallbackEvent evt; evt.eventType = CafeTitleListCallbackEvent::TYPE::TITLE_REMOVED; evt.titleInfo = itPending; for (auto& it : sTLCallbackList) it.cb(&evt, it.ctx); delete itPending; } sTLListPending.clear(); } sTLMutex.lock(); sTLRefreshWorkerActive = false; // send notification that scanning finished CafeTitleListCallbackEvent evt; evt.eventType = CafeTitleListCallbackEvent::TYPE::SCAN_FINISHED; evt.titleInfo = nullptr; for (auto& it : sTLCallbackList) it.cb(&evt, it.ctx); sTLMutex.unlock(); if (sTLCacheDirty) { StoreCacheFile(); sTLCacheDirty = false; } return true; } bool _IsKnownFileNameOrExtension(const fs::path& path) { std::string fileExtension = _pathToUtf8(path.extension()); for (auto& it : fileExtension) it = _ansiToLower(it); if(fileExtension == ".tmd") { // must be "title.tmd" std::string fileName = _pathToUtf8(path.filename()); for (auto& it : fileName) it = _ansiToLower(it); return fileName == "title.tmd"; } return fileExtension == ".wud" || fileExtension == ".wux" || fileExtension == ".iso" || fileExtension == ".wua"; // note: To detect extracted titles with RPX we rely on the presence of the content,code,meta directory structure } void CafeTitleList::ScanGamePath(const fs::path& path) { // scan the whole directory first to determine if this is a title folder std::vector filesInDirectory; std::vector dirsInDirectory; bool hasContentFolder = false, hasCodeFolder = false, hasMetaFolder = false; std::error_code ec; for (auto& it : fs::directory_iterator(path, ec)) { if (it.is_regular_file(ec)) { filesInDirectory.emplace_back(it.path()); } else if (it.is_directory(ec)) { dirsInDirectory.emplace_back(it.path()); std::string dirName = _pathToUtf8(it.path().filename()); if (boost::iequals(dirName, "content")) hasContentFolder = true; else if (boost::iequals(dirName, "code")) hasCodeFolder = true; else if (boost::iequals(dirName, "meta")) hasMetaFolder = true; } } // always check individual files for (auto& it : filesInDirectory) { // since checking individual files is slow, we limit it to known file names or extensions if (!it.has_extension()) continue; if (!_IsKnownFileNameOrExtension(it)) continue; AddTitleFromPath(it); } // is the current directory a title folder? if (hasContentFolder && hasCodeFolder && hasMetaFolder) { // verify if this folder is a valid title TitleInfo* titleInfo = new TitleInfo(path); if (titleInfo->IsValid()) AddDiscoveredTitle(titleInfo); else delete titleInfo; // if there are other folders besides content/code/meta then traverse those if (dirsInDirectory.size() > 3) { for (auto& it : dirsInDirectory) { std::string dirName = _pathToUtf8(it.filename()); if (!boost::iequals(dirName, "content") && !boost::iequals(dirName, "code") && !boost::iequals(dirName, "meta")) ScanGamePath(it); } } } else { // scan subdirectories for (auto& it : dirsInDirectory) ScanGamePath(it); } } void CafeTitleList::ScanMLCPath(const fs::path& path) { std::error_code ec; for (auto& it : fs::directory_iterator(path, ec)) { if (!it.is_directory()) continue; // only scan directories which match the title id naming scheme std::string dirName = _pathToUtf8(it.path().filename()); if(dirName.size() != 8) continue; bool containsNoHexCharacter = false; for (auto& it : dirName) { if(it >= 'A' && it <= 'F' || it >= 'a' && it <= 'f' || it >= '0' && it <= '9') continue; containsNoHexCharacter = true; break; } if(containsNoHexCharacter) continue; if (fs::is_directory(it.path() / "code", ec) && fs::is_directory(it.path() / "content", ec) && fs::is_directory(it.path() / "meta", ec)) { TitleInfo* titleInfo = new TitleInfo(it); if (titleInfo->IsValid() && titleInfo->ParseXmlInfo()) AddDiscoveredTitle(titleInfo); else delete titleInfo; } } } void CafeTitleList::AddDiscoveredTitle(TitleInfo* titleInfo) { cemu_assert_debug(titleInfo->ParseXmlInfo()); std::unique_lock _lock(sTLMutex); // remove from pending list auto pendingIt = std::find_if(sTLListPending.begin(), sTLListPending.end(), [titleInfo](const TitleInfo* it) { return it->IsEqualByLocation(*titleInfo); }); if (pendingIt != sTLListPending.end()) sTLListPending.erase(pendingIt); AddTitle(titleInfo); } void CafeTitleList::AddTitle(TitleInfo* titleInfo) { // check if title is already known if (titleInfo->IsCached()) { bool isKnown = std::any_of(sTLList.cbegin(), sTLList.cend(), [&titleInfo](const TitleInfo* ti) { return titleInfo->IsEqualByLocation(*ti); }); if (isKnown) { delete titleInfo; return; } } else { auto it = std::find_if(sTLList.begin(), sTLList.end(), [titleInfo](const TitleInfo* it) { return it->IsEqualByLocation(*titleInfo); }); if (it != sTLList.end()) { if ((*it)->IsCached()) { // replace cached entry with newly parsed title TitleInfo* deletedInfo = *it; sTLList.erase(it); _RemoveTitleFromMultimap(deletedInfo); delete deletedInfo; } else { // title already known delete titleInfo; return; } } } sTLList.emplace_back(titleInfo); sTLMap.emplace(titleInfo->GetAppTitleId(), titleInfo); // send out notification CafeTitleListCallbackEvent evt; evt.eventType = CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED; evt.titleInfo = titleInfo; for (auto& it : sTLCallbackList) it.cb(&evt, it.ctx); sTLCacheDirty = true; } uint64 CafeTitleList::RegisterCallback(void(*cb)(CafeTitleListCallbackEvent* evt, void* ctx), void* ctx) { static std::atomic sCallbackIdGen = 1; uint64 id = sCallbackIdGen.fetch_add(1); std::unique_lock _lock(sTLMutex); sTLCallbackList.emplace_back(cb, ctx, id); // immediately notify of all known titles for (auto& it : sTLList) { CafeTitleListCallbackEvent evt; evt.eventType = CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED; evt.titleInfo = it; cb(&evt, ctx); } // if not scanning then send out scan finished notification if (!sTLRefreshWorkerActive) { CafeTitleListCallbackEvent evt; evt.eventType = CafeTitleListCallbackEvent::TYPE::SCAN_FINISHED; evt.titleInfo = nullptr; for (auto& it : sTLCallbackList) it.cb(&evt, it.ctx); } return id; } void CafeTitleList::UnregisterCallback(uint64 id) { std::unique_lock _lock(sTLMutex); auto it = std::find_if(sTLCallbackList.begin(), sTLCallbackList.end(), [id](auto& e) { return e.uniqueId == id; }); cemu_assert(it != sTLCallbackList.end()); // must be a valid callback sTLCallbackList.erase(it); } bool CafeTitleList::HasTitle(TitleId titleId, uint16& versionOut) { // todo - optimize? bool matchFound = false; versionOut = 0; std::unique_lock _lock(sTLMutex); for (auto& it : sTLList) { if (it->GetAppTitleId() == titleId) { uint16 titleVersion = it->GetAppTitleVersion(); if (titleVersion > versionOut) versionOut = titleVersion; matchFound = true; } } return matchFound; } bool CafeTitleList::HasTitleAndVersion(TitleId titleId, uint16 version) { std::unique_lock _lock(sTLMutex); for (auto& it : sTLList) { if (it->GetAppTitleId() == titleId && it->GetAppTitleVersion() == version) return true; } return false; } std::vector CafeTitleList::GetAllTitleIds() { std::unordered_set visitedTitleIds; std::unique_lock _lock(sTLMutex); std::vector titleIds; titleIds.reserve(sTLList.size()); for (auto& it : sTLList) { TitleId tid = it->GetAppTitleId(); if (visitedTitleIds.find(tid) != visitedTitleIds.end()) continue; titleIds.emplace_back(tid); visitedTitleIds.emplace(tid); } return titleIds; } std::span CafeTitleList::AcquireInternalList() { sTLMutex.lock(); return { sTLList.data(), sTLList.size() }; } void CafeTitleList::ReleaseInternalList() { sTLMutex.unlock(); } bool CafeTitleList::GetFirstByTitleId(TitleId titleId, TitleInfo& titleInfoOut) { std::unique_lock _lock(sTLMutex); auto it = sTLMap.find(titleId); if (it != sTLMap.end()) { cemu_assert_debug(it->first == titleId); titleInfoOut = *it->second; return true; } return false; } // takes update or AOC title id and returns the title id of the associated base title // this can fail if trying to translate an AOC title id without having the base title meta information bool CafeTitleList::FindBaseTitleId(TitleId titleId, TitleId& titleIdBaseOut) { titleId = TitleIdParser::MakeBaseTitleId(titleId); // aoc to base // todo - this requires scanning all base titles and their updates to see if they reference this title id // for now we assume there is a direct match of ids if (((titleId >> 32) & 0xFF) == 0x0C) { titleId &= ~0xFF00000000; titleId |= 0x0000000000; } titleIdBaseOut = titleId; return true; } GameInfo2 CafeTitleList::GetGameInfo(TitleId titleId) { GameInfo2 gameInfo; // find base title id uint64 baseTitleId; if (!FindBaseTitleId(titleId, baseTitleId)) { cemu_assert_suspicious(); } // determine if an optional update title id exists TitleIdParser tip(baseTitleId); bool hasSeparateUpdateTitleId = tip.CanHaveSeparateUpdateTitleId(); uint64 updateTitleId = 0; if (hasSeparateUpdateTitleId) updateTitleId = tip.GetSeparateUpdateTitleId(); // scan the title list for base and update std::unique_lock _lock(sTLMutex); for (auto& it : sTLList) { TitleId appTitleId = it->GetAppTitleId(); if (appTitleId == baseTitleId) { gameInfo.SetBase(*it); } if (hasSeparateUpdateTitleId && appTitleId == updateTitleId) { gameInfo.SetUpdate(*it); } } // if this title can have AOC content then do a second scan // todo - get a list of all AOC title ids from the base/update meta information // for now we assume there is a direct match between the base titleId and the aoc titleId if (tip.CanHaveSeparateUpdateTitleId()) { uint64 aocTitleId = baseTitleId | 0xC00000000; for (auto& it : sTLList) { TitleId appTitleId = it->GetAppTitleId(); if (appTitleId == aocTitleId) { gameInfo.AddAOC(*it); // stores the AOC with the highest title version } } } return gameInfo; } TitleInfo CafeTitleList::GetTitleInfoByUID(uint64 uid) { TitleInfo titleInfo; std::unique_lock _lock(sTLMutex); for (auto& it : sTLList) { if (it->GetUID() == uid) { titleInfo = *it; break; } } return titleInfo; }