aboutsummaryrefslogtreecommitdiff
path: root/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'src/core')
-rw-r--r--src/core/CMakeLists.txt32
-rw-r--r--src/core/core.cpp5
-rw-r--r--src/core/hle/service/am/am_types.h1
-rw-r--r--src/core/hle/service/am/frontend/applet_web_browser.cpp130
-rw-r--r--src/core/hle/service/am/frontend/applet_web_browser.h5
-rw-r--r--src/core/hle/service/am/frontend/applet_web_browser_types.h4
-rw-r--r--src/core/hle/service/am/frontend/applets.cpp4
-rw-r--r--src/core/hle/service/am/service/application_creator.cpp3
-rw-r--r--src/core/hle/service/bcat/news/builtin_news.cpp555
-rw-r--r--src/core/hle/service/bcat/news/builtin_news.h27
-rw-r--r--src/core/hle/service/bcat/news/msgpack.cpp886
-rw-r--r--src/core/hle/service/bcat/news/msgpack.h149
-rw-r--r--src/core/hle/service/bcat/news/news_data_service.cpp116
-rw-r--r--src/core/hle/service/bcat/news/news_data_service.h17
-rw-r--r--src/core/hle/service/bcat/news/news_database_service.cpp188
-rw-r--r--src/core/hle/service/bcat/news/news_database_service.h28
-rw-r--r--src/core/hle/service/bcat/news/news_service.cpp72
-rw-r--r--src/core/hle/service/bcat/news/news_service.h13
-rw-r--r--src/core/hle/service/bcat/news/news_storage.cpp169
-rw-r--r--src/core/hle/service/bcat/news/news_storage.h100
-rw-r--r--src/core/hle/service/ns/application_manager_interface.cpp53
-rw-r--r--src/core/hle/service/ns/ns_types.h9
-rw-r--r--src/core/launch_timestamp_cache.cpp135
-rw-r--r--src/core/launch_timestamp_cache.h13
24 files changed, 2617 insertions, 97 deletions
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index a961eff8bf..ec7ce42a1b 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -563,8 +563,14 @@ add_library(core STATIC
hle/service/bcat/delivery_cache_storage_service.h
hle/service/bcat/news/newly_arrived_event_holder.cpp
hle/service/bcat/news/newly_arrived_event_holder.h
+ hle/service/bcat/news/msgpack.cpp
+ hle/service/bcat/news/msgpack.h
hle/service/bcat/news/news_data_service.cpp
hle/service/bcat/news/news_data_service.h
+ hle/service/bcat/news/builtin_news.cpp
+ hle/service/bcat/news/builtin_news.h
+ hle/service/bcat/news/news_storage.cpp
+ hle/service/bcat/news/news_storage.h
hle/service/bcat/news/news_database_service.cpp
hle/service/bcat/news/news_database_service.h
hle/service/bcat/news/news_service.cpp
@@ -1125,6 +1131,8 @@ add_library(core STATIC
internal_network/sockets.h
internal_network/wifi_scanner.cpp
internal_network/wifi_scanner.h
+ launch_timestamp_cache.cpp
+ launch_timestamp_cache.h
loader/deconstructed_rom_directory.cpp
loader/deconstructed_rom_directory.h
loader/kip.cpp
@@ -1157,8 +1165,7 @@ add_library(core STATIC
tools/freezer.cpp
tools/freezer.h
tools/renderdoc.cpp
- tools/renderdoc.h
-)
+ tools/renderdoc.h)
if (ENABLE_WIFI_SCAN)
# find_package(libiw REQUIRED)
@@ -1188,8 +1195,7 @@ else()
-Werror=conversion
-Wno-sign-conversion
-Wno-cast-function-type
- $<$<CXX_COMPILER_ID:Clang>:-fsized-deallocation>
- )
+ $<$<CXX_COMPILER_ID:Clang>:-fsized-deallocation>)
# pre-clang19 will spam with "OH DID YOU MEAN THIS?" otherwise...
if (CXX_CLANG AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19)
target_compile_options(core PRIVATE -Wno-cast-function-type-mismatch)
@@ -1205,10 +1211,13 @@ else()
target_link_libraries(core PUBLIC Boost::headers)
endif()
-target_link_libraries(core PRIVATE fmt::fmt nlohmann_json::nlohmann_json RenderDoc::API MbedTLS::mbedcrypto${MBEDTLS_LIB_SUFFIX} MbedTLS::mbedtls${MBEDTLS_LIB_SUFFIX})
-# if (MINGW)
-# target_link_libraries(core PRIVATE ws2_32 mswsock wlanapi)
-# endif()
+target_link_libraries(core PRIVATE
+ fmt::fmt
+ nlohmann_json::nlohmann_json
+ RenderDoc::API
+ MbedTLS::mbedcrypto${MBEDTLS_LIB_SUFFIX}
+ MbedTLS::mbedtls${MBEDTLS_LIB_SUFFIX}
+ httplib::httplib)
if (ENABLE_WEB_SERVICE)
target_compile_definitions(core PUBLIC ENABLE_WEB_SERVICE)
@@ -1230,8 +1239,7 @@ if (HAS_NCE)
arm/nce/interpreter_visitor.h
arm/nce/patcher.cpp
arm/nce/patcher.h
- arm/nce/visitor_base.h
- )
+ arm/nce/visitor_base.h)
target_link_libraries(core PRIVATE merry::oaknut)
endif()
@@ -1251,8 +1259,7 @@ if (ARCHITECTURE_x86_64 OR ARCHITECTURE_arm64)
hle/service/jit/jit_context.cpp
hle/service/jit/jit_context.h
hle/service/jit/jit.cpp
- hle/service/jit/jit.h
- )
+ hle/service/jit/jit.h)
target_link_libraries(core PRIVATE dynarmic::dynarmic)
endif()
@@ -1263,6 +1270,7 @@ if(ENABLE_OPENSSL)
find_package(OpenSSL REQUIRED)
target_link_libraries(core PRIVATE OpenSSL::SSL OpenSSL::Crypto)
+ target_compile_definitions(core PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)
elseif (APPLE)
target_sources(core PRIVATE
hle/service/ssl/ssl_backend_securetransport.cpp)
diff --git a/src/core/core.cpp b/src/core/core.cpp
index aea2b2b060..bada8ef2c1 100644
--- a/src/core/core.cpp
+++ b/src/core/core.cpp
@@ -15,6 +15,8 @@
#include "common/string_util.h"
#include "core/arm/exclusive_monitor.h"
#include "core/core.h"
+
+#include "launch_timestamp_cache.h"
#include "core/core_timing.h"
#include "core/cpu_manager.h"
#include "core/debugger/debugger.h"
@@ -323,6 +325,9 @@ struct System::Impl {
LOG_INFO(Core, "Loading {} ({:016X}) ...", name, params.program_id);
+ // Track launch time for frontend launches
+ LaunchTimestampCache::SaveLaunchTimestamp(params.program_id);
+
// Make the process created be the application
kernel.MakeApplicationProcess(process->GetHandle());
diff --git a/src/core/hle/service/am/am_types.h b/src/core/hle/service/am/am_types.h
index 621a7b4921..beb52b74dd 100644
--- a/src/core/hle/service/am/am_types.h
+++ b/src/core/hle/service/am/am_types.h
@@ -94,6 +94,7 @@ enum class AppletId : u32 {
LoginShare = 0x18,
WebAuth = 0x19,
MyPage = 0x1A,
+ Lhub = 0x35
};
enum class AppletProgramId : u64 {
diff --git a/src/core/hle/service/am/frontend/applet_web_browser.cpp b/src/core/hle/service/am/frontend/applet_web_browser.cpp
index 8246b3e88e..e7cbdf6361 100644
--- a/src/core/hle/service/am/frontend/applet_web_browser.cpp
+++ b/src/core/hle/service/am/frontend/applet_web_browser.cpp
@@ -236,10 +236,6 @@ WebBrowser::WebBrowser(Core::System& system_, std::shared_ptr<Applet> applet_,
WebBrowser::~WebBrowser() = default;
void WebBrowser::Initialize() {
- if (Settings::values.disable_web_applet) {
- return;
- }
-
FrontendApplet::Initialize();
LOG_INFO(Service_AM, "Initializing Web Browser Applet.");
@@ -264,6 +260,12 @@ void WebBrowser::Initialize() {
LOG_DEBUG(Service_AM, "WebArgHeader: total_tlv_entries={}, shim_kind={}",
web_arg_header.total_tlv_entries, web_arg_header.shim_kind);
+ if (Settings::values.disable_web_applet &&
+ web_arg_header.shim_kind != ShimKind::Web &&
+ web_arg_header.shim_kind != ShimKind::Lhub) {
+ return;
+ }
+
ExtractSharedFonts(system);
switch (web_arg_header.shim_kind) {
@@ -288,6 +290,9 @@ void WebBrowser::Initialize() {
case ShimKind::Lobby:
InitializeLobby();
break;
+ case ShimKind::Lhub:
+ InitializeLhub();
+ break;
default:
ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind);
break;
@@ -303,8 +308,19 @@ void WebBrowser::ExecuteInteractive() {
}
void WebBrowser::Execute() {
+ if (web_arg_header.shim_kind == ShimKind::Web) {
+ ExecuteWeb();
+ return;
+ }
+
+ if (web_arg_header.shim_kind == ShimKind::Lhub) {
+ ExecuteLhub();
+ return;
+ }
+
if (Settings::values.disable_web_applet) {
- LOG_WARNING(Service_AM, "(STUBBED) called, Web Browser Applet is disabled");
+ LOG_WARNING(Service_AM, "(STUBBED) called, Web Browser Applet is disabled. shim_kind={}",
+ web_arg_header.shim_kind);
WebBrowserExit(WebExitReason::EndButtonPressed);
return;
}
@@ -331,6 +347,9 @@ void WebBrowser::Execute() {
case ShimKind::Lobby:
ExecuteLobby();
break;
+ case ShimKind::Lhub:
+ ExecuteLhub();
+ break;
default:
ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind);
WebBrowserExit(WebExitReason::EndButtonPressed);
@@ -351,17 +370,99 @@ void WebBrowser::ExtractOfflineRomFS() {
}
void WebBrowser::WebBrowserExit(WebExitReason exit_reason, std::string last_url) {
- if ((web_arg_header.shim_kind == ShimKind::Share &&
+ const bool use_tlv_output =
+ (web_arg_header.shim_kind == ShimKind::Share &&
web_applet_version >= WebAppletVersion::Version196608) ||
(web_arg_header.shim_kind == ShimKind::Web &&
- web_applet_version >= WebAppletVersion::Version524288)) {
- // TODO: Push Output TLVs instead of a WebCommonReturnValue
+ web_applet_version >= WebAppletVersion::Version524288) ||
+ (web_arg_header.shim_kind == ShimKind::Lhub);
+
+ // https://switchbrew.org/wiki/Internet_Browser#TLVs
+ if (use_tlv_output) {
+ LOG_DEBUG(Service_AM, "Using TLV output: exit_reason={}, last_url={}, last_url_size={}",
+ exit_reason, last_url, last_url.size());
+
+ // storage size for TLVs is 0x2000 bytes (as per switchbrew documentation)
+ constexpr size_t TLV_STORAGE_SIZE = 0x2000;
+ std::vector<u8> out_data(TLV_STORAGE_SIZE, 0);
+
+ size_t current_offset = sizeof(WebArgHeader);
+ u16 tlv_count = 0;
+
+ // align and matchng TLV struct alignment
+ auto align_offset = [](size_t offset) -> size_t {
+ return (offset + 7) & ~static_cast<size_t>(7);
+ };
+
+ // 0x1 ShareExitReason
+ {
+ WebArgOutputTLV tlv{};
+ tlv.output_tlv_type = WebArgOutputTLVType::ShareExitReason;
+ tlv.arg_data_size = sizeof(u32);
+
+ std::memcpy(out_data.data() + current_offset, &tlv, sizeof(WebArgOutputTLV));
+ current_offset += sizeof(WebArgOutputTLV);
+
+ const u32 exit_reason_value = static_cast<u32>(exit_reason);
+ std::memcpy(out_data.data() + current_offset, &exit_reason_value, sizeof(u32));
+ current_offset += sizeof(u32);
+
+ current_offset = align_offset(current_offset);
+ tlv_count++;
+ }
+
+ // 0x2 LastUrl
+ {
+ WebArgOutputTLV tlv{};
+ tlv.output_tlv_type = WebArgOutputTLVType::LastURL;
+ const u16 url_data_size = static_cast<u16>(last_url.size() + 1);
+ tlv.arg_data_size = url_data_size;
+
+ std::memcpy(out_data.data() + current_offset, &tlv, sizeof(WebArgOutputTLV));
+ current_offset += sizeof(WebArgOutputTLV);
+
+ // null terminator
+ std::memcpy(out_data.data() + current_offset, last_url.c_str(), last_url.size() + 1);
+ current_offset += url_data_size;
+ current_offset = align_offset(current_offset);
+ tlv_count++;
+ }
+
+ // 0x3 LastUrlSize
+ {
+ WebArgOutputTLV tlv{};
+ tlv.output_tlv_type = WebArgOutputTLVType::LastURLSize;
+ tlv.arg_data_size = sizeof(u64);
+
+ std::memcpy(out_data.data() + current_offset, &tlv, sizeof(WebArgOutputTLV));
+ current_offset += sizeof(WebArgOutputTLV);
+
+ const u64 url_size = last_url.size();
+ std::memcpy(out_data.data() + current_offset, &url_size, sizeof(u64));
+ current_offset += sizeof(u64);
+ tlv_count++;
+ }
+
+ WebArgHeader out_header{};
+ out_header.total_tlv_entries = tlv_count;
+ out_header.shim_kind = web_arg_header.shim_kind;
+ std::memcpy(out_data.data(), &out_header, sizeof(WebArgHeader));
+
+ LOG_DEBUG(Service_AM, "TLV output: total_size={}, tlv_count={}, used_offset={}",
+ out_data.size(), tlv_count, current_offset);
+
+ complete = true;
+ PushOutData(std::make_shared<IStorage>(system, std::move(out_data)));
+ Exit();
+ return;
}
- WebCommonReturnValue web_common_return_value;
+ // for old browser, keep old return, use WebCommonReturnValue
+ WebCommonReturnValue web_common_return_value{};
web_common_return_value.exit_reason = exit_reason;
- std::memcpy(&web_common_return_value.last_url, last_url.data(), last_url.size());
+ std::memcpy(&web_common_return_value.last_url, last_url.data(),
+ std::min(last_url.size(), web_common_return_value.last_url.size()));
web_common_return_value.last_url_size = last_url.size();
LOG_DEBUG(Service_AM, "WebCommonReturnValue: exit_reason={}, last_url={}, last_url_size={}",
@@ -516,4 +617,13 @@ void WebBrowser::ExecuteLobby() {
LOG_WARNING(Service_AM, "(STUBBED) called, Lobby Applet is not implemented");
WebBrowserExit(WebExitReason::EndButtonPressed);
}
+
+void WebBrowser::InitializeLhub() {}
+
+void WebBrowser::ExecuteLhub() {
+ LOG_INFO(Service_AM, "(STUBBED) called, Lhub Applet is not implemented");
+ WebBrowserExit(WebExitReason::EndButtonPressed);
+}
+
+
} // namespace Service::AM::Frontend
diff --git a/src/core/hle/service/am/frontend/applet_web_browser.h b/src/core/hle/service/am/frontend/applet_web_browser.h
index ba20b7a4cf..ae62389f13 100644
--- a/src/core/hle/service/am/frontend/applet_web_browser.h
+++ b/src/core/hle/service/am/frontend/applet_web_browser.h
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -53,6 +56,7 @@ private:
void InitializeWeb();
void InitializeWifi();
void InitializeLobby();
+ void InitializeLhub();
// Executors for the various types of browser applets
void ExecuteShop();
@@ -62,6 +66,7 @@ private:
void ExecuteWeb();
void ExecuteWifi();
void ExecuteLobby();
+ void ExecuteLhub();
const Core::Frontend::WebBrowserApplet& frontend;
diff --git a/src/core/hle/service/am/frontend/applet_web_browser_types.h b/src/core/hle/service/am/frontend/applet_web_browser_types.h
index 2f7c05c243..0892a6a716 100644
--- a/src/core/hle/service/am/frontend/applet_web_browser_types.h
+++ b/src/core/hle/service/am/frontend/applet_web_browser_types.h
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -30,6 +33,7 @@ enum class ShimKind : u32 {
Web = 5,
Wifi = 6,
Lobby = 7,
+ Lhub = 8,
};
enum class WebExitReason : u32 {
diff --git a/src/core/hle/service/am/frontend/applets.cpp b/src/core/hle/service/am/frontend/applets.cpp
index a25b7e3aa2..2ff2bcdbb6 100644
--- a/src/core/hle/service/am/frontend/applets.cpp
+++ b/src/core/hle/service/am/frontend/applets.cpp
@@ -237,13 +237,15 @@ std::shared_ptr<FrontendApplet> FrontendAppletHolder::GetApplet(std::shared_ptr<
case AppletId::OfflineWeb:
case AppletId::LoginShare:
case AppletId::WebAuth:
+ case AppletId::Lhub:
return std::make_shared<WebBrowser>(system, applet, mode, *frontend.web_browser);
case AppletId::PhotoViewer:
return std::make_shared<PhotoViewer>(system, applet, mode, *frontend.photo_viewer);
case AppletId::NetConnect:
return std::make_shared<NetConnect>(system, applet, mode, *frontend.net_connect);
default:
- LOG_ERROR(Service_AM, "No backend implementation exists for applet_id={:02X}. Falling back to stub applet", static_cast<u8>(id));
+ LOG_ERROR(Service_AM, "No backend implementation exists for applet_id={:02X} program_id={:016X}"
+ "Falling back to stub applet", static_cast<u8>(id), applet->program_id);
return std::make_shared<StubApplet>(system, applet, id, mode);
}
}
diff --git a/src/core/hle/service/am/service/application_creator.cpp b/src/core/hle/service/am/service/application_creator.cpp
index 2fc33a303c..d16fd7dd84 100644
--- a/src/core/hle/service/am/service/application_creator.cpp
+++ b/src/core/hle/service/am/service/application_creator.cpp
@@ -15,6 +15,7 @@
#include "core/hle/service/am/window_system.h"
#include "core/hle/service/cmif_serialization.h"
#include "core/loader/loader.h"
+#include "core/launch_timestamp_cache.h"
namespace Service::AM {
@@ -72,6 +73,7 @@ IApplicationCreator::~IApplicationCreator() = default;
Result IApplicationCreator::CreateApplication(
Out<SharedPointer<IApplicationAccessor>> out_application_accessor, u64 application_id) {
LOG_INFO(Service_NS, "called, application_id={:016X}", application_id);
+ Core::LaunchTimestampCache::SaveLaunchTimestamp(application_id);
R_RETURN(
CreateGuestApplication(out_application_accessor, system, m_window_system, application_id));
}
@@ -103,6 +105,7 @@ Result IApplicationCreator::CreateSystemApplication(
*out_application_accessor =
std::make_shared<IApplicationAccessor>(system, applet, m_window_system);
+ Core::LaunchTimestampCache::SaveLaunchTimestamp(application_id);
R_SUCCEED();
}
diff --git a/src/core/hle/service/bcat/news/builtin_news.cpp b/src/core/hle/service/bcat/news/builtin_news.cpp
new file mode 100644
index 0000000000..440a5d1f51
--- /dev/null
+++ b/src/core/hle/service/bcat/news/builtin_news.cpp
@@ -0,0 +1,555 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/hle/service/bcat/news/builtin_news.h"
+#include "core/hle/service/bcat/news/msgpack.h"
+#include "core/hle/service/bcat/news/news_storage.h"
+
+#include "common/fs/file.h"
+#include "common/fs/path_util.h"
+#include "common/logging/log.h"
+
+#include <boost/algorithm/string/replace.hpp>
+#include <boost/regex.hpp>
+#include <boost/regex/v5/regex_replace.hpp>
+#include <fmt/format.h>
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
+#include <httplib.h>
+#endif
+
+#include <chrono>
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <future>
+#include <iomanip>
+#include <mutex>
+#include <optional>
+#include <sstream>
+#include <thread>
+
+#ifdef YUZU_BUNDLED_OPENSSL
+#include <openssl/cert.h>
+#endif
+
+namespace Service::News {
+namespace {
+
+constexpr const char* GitHubAPI_EdenReleases = "/repos/eden-emulator/Releases/releases";
+
+// Cached logo data
+std::vector<u8> default_logo_small;
+std::vector<u8> default_logo_large;
+bool default_logos_loaded = false;
+
+std::unordered_map<std::string, std::vector<u8>> news_images_small;
+std::unordered_map<std::string, std::vector<u8>> news_images_large;
+std::mutex images_mutex;
+
+
+std::filesystem::path GetCachePath() {
+ return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "github_releases.json";
+}
+
+std::filesystem::path GetDefaultLogoPath(bool large) {
+ return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" /
+ (large ? "eden_logo_large.jpg" : "eden_logo_small.jpg");
+}
+
+std::filesystem::path GetNewsImagePath(std::string_view news_id, bool large) {
+ const std::string filename = fmt::format("{}_{}.jpg", news_id, large ? "large" : "small");
+ return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "images" / filename;
+}
+
+u32 HashToNewsId(std::string_view key) {
+ return static_cast<u32>(std::hash<std::string_view>{}(key) & 0x7FFFFFFF);
+}
+
+u64 ParseIsoTimestamp(const std::string& iso) {
+ if (iso.empty()) return 0;
+
+ std::string buf = iso;
+ if (buf.back() == 'Z') buf.pop_back();
+
+ std::tm tm{};
+ std::istringstream ss(buf);
+ ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
+ if (ss.fail()) return 0;
+
+#ifdef _WIN32
+ return static_cast<u64>(_mkgmtime(&tm));
+#else
+ return static_cast<u64>(timegm(&tm));
+#endif
+}
+
+std::vector<u8> TryLoadFromDisk(const std::filesystem::path& path) {
+ if (!std::filesystem::exists(path)) return {};
+
+ std::ifstream f(path, std::ios::binary | std::ios::ate);
+ if (!f) return {};
+
+ const auto file_size = static_cast<std::streamsize>(f.tellg());
+ if (file_size <= 0 || file_size > 10 * 1024 * 1024) return {};
+
+ f.seekg(0);
+ std::vector<u8> data(static_cast<size_t>(file_size));
+ if (!f.read(reinterpret_cast<char*>(data.data()), file_size)) return {};
+
+ return data;
+}
+
+std::vector<u8> DownloadImage(const std::string& url_path, const std::filesystem::path& cache_path) {
+ LOG_INFO(Service_BCAT, "Downloading image: https://eden-emu.dev{}", url_path);
+
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
+ try {
+ httplib::Client cli("https://eden-emu.dev");
+ cli.set_follow_location(true);
+ cli.set_connection_timeout(std::chrono::seconds(10));
+ cli.set_read_timeout(std::chrono::seconds(30));
+
+#ifdef YUZU_BUNDLED_OPENSSL
+ cli.load_ca_cert_store(kCert, sizeof(kCert));
+#endif
+
+ if (auto res = cli.Get(url_path); res && res->status == 200 && !res->body.empty()) {
+ std::vector<u8> data(res->body.begin(), res->body.end());
+
+ std::error_code ec;
+ std::filesystem::create_directories(cache_path.parent_path(), ec);
+ if (std::ofstream out(cache_path, std::ios::binary); out) {
+ out.write(res->body.data(), static_cast<std::streamsize>(res->body.size()));
+ }
+ return data;
+ }
+ } catch (...) {
+ LOG_WARNING(Service_BCAT, "Failed to download: {}", url_path);
+ }
+#endif
+
+ return {};
+}
+
+std::vector<u8> LoadDefaultLogo(bool large) {
+ const auto path = GetDefaultLogoPath(large);
+ const std::string url = large ? "/news/eden_logo_large.jpg" : "/news/eden_logo_small.jpg";
+
+ auto data = TryLoadFromDisk(path);
+ if (!data.empty()) return data;
+
+ return DownloadImage(url, path);
+}
+
+void LoadDefaultLogos() {
+ if (default_logos_loaded) return;
+ default_logos_loaded = true;
+
+ default_logo_small = LoadDefaultLogo(false);
+ default_logo_large = LoadDefaultLogo(true);
+}
+
+std::vector<u8> GetNewsImage(std::string_view news_id, bool large) {
+ const std::string id_str{news_id};
+
+ {
+ std::lock_guard lock{images_mutex};
+ auto& cache = large ? news_images_large : news_images_small;
+ if (auto it = cache.find(id_str); it != cache.end()) {
+ return it->second;
+ }
+ }
+
+ const auto cache_path = GetNewsImagePath(news_id, large);
+ auto data = TryLoadFromDisk(cache_path);
+
+ if (data.empty()) {
+ const std::string url = fmt::format("/news/{}_{}.jpg", id_str, large ? "large" : "small");
+ data = DownloadImage(url, cache_path);
+ }
+
+ if (data.empty()) {
+ data = large ? default_logo_large : default_logo_small;
+ }
+
+ {
+ std::lock_guard lock{images_mutex};
+ auto& cache = large ? news_images_large : news_images_small;
+ cache[id_str] = data;
+ }
+
+ return data;
+}
+
+void PreloadNewsImages(const std::vector<u32>& news_ids) {
+ std::vector<std::future<void>> futures;
+ futures.reserve(news_ids.size() * 2);
+
+ for (const u32 id : news_ids) {
+ const std::string id_str = fmt::format("{}", id);
+
+ {
+ std::lock_guard lock{images_mutex};
+ if (news_images_small.contains(id_str) && news_images_large.contains(id_str)) {
+ continue;
+ }
+ }
+
+ const auto path_small = GetNewsImagePath(id_str, false);
+ const auto path_large = GetNewsImagePath(id_str, true);
+ if (std::filesystem::exists(path_small) && std::filesystem::exists(path_large)) {
+ continue;
+ }
+
+ futures.push_back(std::async(std::launch::async, [id_str]() {
+ GetNewsImage(id_str, false);
+ }));
+ futures.push_back(std::async(std::launch::async, [id_str]() {
+ GetNewsImage(id_str, true);
+ }));
+ }
+
+ for (auto& f : futures) {
+ f.wait();
+ }
+}
+
+std::optional<std::string> ReadCachedJson() {
+ const auto path = GetCachePath();
+ if (!std::filesystem::exists(path)) return std::nullopt;
+
+ auto content = Common::FS::ReadStringFromFile(path, Common::FS::FileType::TextFile);
+ return content.empty() ? std::nullopt : std::optional{std::move(content)};
+}
+
+void WriteCachedJson(std::string_view json) {
+ const auto path = GetCachePath();
+ std::error_code ec;
+ std::filesystem::create_directories(path.parent_path(), ec);
+ (void)Common::FS::WriteStringToFile(path, Common::FS::FileType::TextFile, json);
+}
+
+std::optional<std::string> DownloadReleasesJson() {
+
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
+ try {
+ httplib::SSLClient cli{"api.github.com", 443};
+ cli.set_connection_timeout(10);
+ cli.set_read_timeout(10);
+
+ httplib::Headers headers{
+ {"User-Agent", "Eden"},
+ {"Accept", "application/vnd.github+json"},
+ };
+
+ // TODO(crueter): automate this in some way...
+#ifdef YUZU_BUNDLED_OPENSSL
+ cli.load_ca_cert_store(kCert, sizeof(kCert));
+#endif
+
+ if (auto res = cli.Get(GitHubAPI_EdenReleases, headers); res && res->status < 400) {
+ return res->body;
+ }
+ } catch (...) {
+ LOG_WARNING(Service_BCAT, " failed to download releases");
+ }
+#endif
+ return std::nullopt;
+}
+
+// idk but News App does not render Markdown or HTML, so remove some formatting.
+std::string SanitizeMarkdown(std::string_view markdown) {
+ std::string result;
+ result.reserve(markdown.size());
+
+ // our current structure for markdown is after "# Packages" remove everything.
+ std::string text{markdown};
+ if (auto pos = text.find("# Packages"); pos != std::string::npos) {
+ text = text.substr(0, pos);
+ }
+
+ // Fix line endings
+ boost::replace_all(text, "\r", "");
+
+ // Remove backticks
+ boost::replace_all(text, "`", "");
+
+ // Remove excessive newlines
+ static const boost::regex newlines(R"(\n\n\n+)");
+ text = boost::regex_replace(text, newlines, "\n\n");
+
+ // Remove markdown headers
+ static const boost::regex headers(R"(^#+ )");
+ text = boost::regex_replace(text, headers, "");
+
+ // Convert bullet points to something nicer
+ static const boost::regex list1(R"(^- )");
+ text = boost::regex_replace(text, list1, "• ");
+
+ static const boost::regex list2(R"(^ \* )");
+ text = boost::regex_replace(text, list2, " • ");
+
+ // what
+ static const boost::regex list2_dash(R"(^ - )");
+ text = boost::regex_replace(text, list2_dash, " • ");
+
+ // Convert bold/italic text into normal text
+ static const boost::regex bold(R"(\*\*(.*?)\*\*)");
+ text = boost::regex_replace(text, bold, "$1");
+
+ static const boost::regex italic(R"(\*(.*?)\*)");
+ text = boost::regex_replace(text, italic, "$1");
+
+ // Remove links and convert to normal text
+ static const boost::regex link(R"(\[([^\]]+)\]\([^)]*\))");
+ text = boost::regex_replace(text, link, "$1");
+
+ // Trim trailing whitespace/newlines
+ while (!text.empty() && (text.back() == '\n' || text.back() == ' ')) {
+ text.pop_back();
+ }
+
+ return text;
+}
+
+std::string FormatBody(const nlohmann::json& release, std::string_view title) {
+ std::string body = release.value("body", std::string{});
+
+ if (body.empty()) {
+ return std::string(title);
+ }
+
+ // Sanitize markdown
+ body = SanitizeMarkdown(body);
+
+ // Limit body length - News app has character limits
+ size_t max_body_length = 4000;
+ if (body.size() > max_body_length) {
+ size_t cut_pos = body.rfind('\n', max_body_length);
+ if (cut_pos == std::string::npos || cut_pos < max_body_length / 2) {
+ cut_pos = body.rfind(". ", max_body_length);
+ }
+ if (cut_pos == std::string::npos || cut_pos < max_body_length / 2) {
+ cut_pos = max_body_length;
+ }
+ body = body.substr(0, cut_pos);
+
+ // Trim trailing whitespace
+ while (!body.empty() && (body.back() == '\n' || body.back() == ' ')) {
+ body.pop_back();
+ }
+
+ body += "\n\n... View more on GitHub";
+ }
+
+ return body;
+}
+
+void ImportReleases(std::string_view json_text) {
+ nlohmann::json root;
+ try {
+ root = nlohmann::json::parse(json_text);
+ } catch (...) {
+ LOG_WARNING(Service_BCAT, "failed to parse JSON");
+ return;
+ }
+
+ if (!root.is_array()) return;
+
+ std::vector<u32> news_ids;
+ for (const auto& rel : root) {
+ if (!rel.is_object()) continue;
+ std::string title = rel.value("name", rel.value("tag_name", std::string{}));
+ if (title.empty()) continue;
+
+ const u64 release_id = rel.value("id", 0);
+ const u32 news_id = release_id ? static_cast<u32>(release_id & 0x7FFFFFFF) : HashToNewsId(title);
+ news_ids.push_back(news_id);
+ }
+
+ PreloadNewsImages(news_ids);
+
+ for (const auto& rel : root) {
+ if (!rel.is_object()) continue;
+
+ std::string title = rel.value("name", rel.value("tag_name", std::string{}));
+ if (title.empty()) continue;
+
+ const u64 release_id = rel.value("id", 0);
+ const u32 news_id = release_id ? static_cast<u32>(release_id & 0x7FFFFFFF) : HashToNewsId(title);
+ const u64 published = ParseIsoTimestamp(rel.value("published_at", std::string{}));
+ const u64 pickup_limit = published + 600000000;
+ const u32 priority = rel.value("prerelease", false) ? 1500 : 2500;
+
+ std::string author = "eden";
+ if (rel.contains("author") && rel["author"].is_object()) {
+ author = rel["author"].value("login", "eden");
+ }
+
+ auto payload = BuildMsgpack(title, FormatBody(rel, title), title, published,
+ pickup_limit, priority, {"en"}, author, {},
+ rel.value("html_url", std::string{}), news_id);
+
+ const std::string news_id_str = fmt::format("LA{:020}", news_id);
+
+ GithubNewsMeta meta{
+ .news_id = news_id_str,
+ .topic_id = "1",
+ .published_at = published,
+ .pickup_limit = pickup_limit,
+ .essential_pickup_limit = pickup_limit,
+ .expire_at = 0,
+ .priority = priority,
+ .deletion_priority = 100,
+ .decoration_type = 1,
+ .opted_in = 1,
+ .essential_pickup_limit_flag = 1,
+ .category = 0,
+ .language_mask = 1,
+ };
+
+ NewsStorage::Instance().UpsertRaw(meta, std::move(payload));
+ }
+}
+
+} // anonymous namespace
+
+std::vector<u8> BuildMsgpack(std::string_view title, std::string_view body,
+ std::string_view topic_name, u64 published_at,
+ u64 pickup_limit, u32 priority,
+ const std::vector<std::string>& languages,
+ const std::string& author,
+ const std::vector<std::pair<std::string, std::string>>& /*assets*/,
+ const std::string& html_url,
+ std::optional<u32> override_id) {
+ MsgPack::Writer w;
+
+ const u32 news_id = override_id.value_or(HashToNewsId(title.empty() ? "eden" : title));
+ const std::string news_id_str = fmt::format("{}", news_id);
+
+ const auto img_small = GetNewsImage(news_id_str, false);
+ const auto img_large = GetNewsImage(news_id_str, true);
+
+ w.WriteFixMap(23);
+
+ // Version infos, could exist a 2?
+ w.WriteKey("version");
+ w.WriteFixMap(2);
+ w.WriteKey("format");
+ w.WriteUInt(1);
+ w.WriteKey("semantics");
+ w.WriteUInt(1);
+
+ // Metadata
+ w.WriteKey("news_id");
+ w.WriteUInt(news_id);
+ w.WriteKey("published_at");
+ w.WriteUInt(published_at);
+ w.WriteKey("pickup_limit");
+ w.WriteUInt(pickup_limit);
+ w.WriteKey("priority");
+ w.WriteUInt(priority);
+ w.WriteKey("deletion_priority");
+ w.WriteUInt(100);
+
+ // Language
+ w.WriteKey("language");
+ w.WriteString(languages.empty() ? "en" : languages.front());
+ w.WriteKey("supported_languages");
+ w.WriteFixArray(languages.size());
+ for (const auto& lang : languages) w.WriteString(lang);
+
+ // Display settings
+ w.WriteKey("display_type");
+ w.WriteString("NORMAL");
+ w.WriteKey("topic_id");
+ w.WriteString("eden");
+
+ w.WriteKey("no_photography"); // still show image
+ w.WriteUInt(0);
+ w.WriteKey("surprise"); // no idea
+ w.WriteUInt(0);
+ w.WriteKey("bashotorya"); // no idea
+ w.WriteUInt(0);
+ w.WriteKey("movie");
+ w.WriteUInt(0); // 1 = has video, movie_url must be set but we don't support it yet
+
+ // News Subject (Title)
+ w.WriteKey("subject");
+ w.WriteFixMap(2);
+ w.WriteKey("caption");
+ w.WriteUInt(1);
+ w.WriteKey("text");
+ w.WriteString(title.empty() ? "No title" : title);
+
+ // Topic name = who wrote it
+ w.WriteKey("topic_name");
+ w.WriteString("Eden");
+
+ w.WriteKey("list_image");
+ w.WriteBinary(img_small);
+
+ // Footer
+ w.WriteKey("footer");
+ w.WriteFixMap(1);
+ w.WriteKey("text");
+ w.WriteString("");
+
+ w.WriteKey("allow_domains");
+ w.WriteString("^https?://github.com(/|$)");
+
+ // More link
+ w.WriteKey("more");
+ w.WriteFixMap(1);
+ w.WriteKey("browser");
+ w.WriteFixMap(2);
+ w.WriteKey("url");
+ w.WriteString(html_url);
+ w.WriteKey("text");
+ w.WriteString("Open GitHub");
+
+ // Body
+ w.WriteKey("body");
+ w.WriteFixMap(4);
+ w.WriteKey("text");
+ w.WriteString(body);
+ w.WriteKey("main_image_height");
+ w.WriteUInt(450);
+ w.WriteKey("movie_url");
+ w.WriteString("");
+ w.WriteKey("main_image");
+ w.WriteBinary(img_large);
+
+ // no clue
+ w.WriteKey("contents_descriptors");
+ w.WriteString("");
+ w.WriteKey("interactive_elements");
+ w.WriteString("");
+
+ return w.Take();
+}
+
+void EnsureBuiltinNewsLoaded() {
+ static std::once_flag once;
+ std::call_once(once, [] {
+ LoadDefaultLogos();
+
+ if (const auto cached = ReadCachedJson()) {
+ ImportReleases(*cached);
+ LOG_DEBUG(Service_BCAT, "news: {} entries loaded from cache", NewsStorage::Instance().ListAll().size());
+ }
+
+ std::thread([] {
+ if (const auto fresh = DownloadReleasesJson()) {
+ WriteCachedJson(*fresh);
+ ImportReleases(*fresh);
+ LOG_DEBUG(Service_BCAT, "news: {} entries updated from GitHub", NewsStorage::Instance().ListAll().size());
+ }
+ }).detach();
+ });
+}
+
+} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/builtin_news.h b/src/core/hle/service/bcat/news/builtin_news.h
new file mode 100644
index 0000000000..cb06b2d4e3
--- /dev/null
+++ b/src/core/hle/service/bcat/news/builtin_news.h
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include "common/common_types.h"
+#include <mutex>
+#include <optional>
+#include <string_view>
+#include <vector>
+
+namespace Service::News {
+
+void EnsureBuiltinNewsLoaded();
+
+ std::vector<u8> BuildMsgpack(std::string_view title, std::string_view body,
+ std::string_view topic_name,
+ u64 published_at,
+ u64 pickup_limit,
+ u32 priority,
+ const std::vector<std::string>& languages,
+ const std::string& author_name,
+ const std::vector<std::pair<std::string, std::string>>& assets,
+ const std::string& html_url,
+ std::optional<u32> override_news_id = std::nullopt);
+
+} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/msgpack.cpp b/src/core/hle/service/bcat/news/msgpack.cpp
new file mode 100644
index 0000000000..b67b75ea9d
--- /dev/null
+++ b/src/core/hle/service/bcat/news/msgpack.cpp
@@ -0,0 +1,886 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/hle/service/bcat/news/msgpack.h"
+
+#include <algorithm>
+#include <cstring>
+
+// This file is a partial MsgPack implementation, only implementing the features
+// needed for the News service. Can be extended but enough for the news use case.
+
+namespace Service::News {
+
+void MsgPack::Writer::WriteBytes(std::span<const u8> bytes) {
+ out.insert(out.end(), bytes.begin(), bytes.end());
+}
+
+void MsgPack::Writer::WriteFixMap(size_t count) {
+ if (count <= 15) {
+ out.push_back(static_cast<u8>(0x80 | count));
+ } else if (count <= 0xFFFF) {
+ out.push_back(0xDE);
+ out.push_back(static_cast<u8>((count >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(count & 0xFF));
+ } else {
+ WriteMap32(count);
+ }
+}
+
+void MsgPack::Writer::WriteMap32(size_t count) {
+ out.push_back(0xDF);
+ out.push_back(static_cast<u8>((count >> 24) & 0xFF));
+ out.push_back(static_cast<u8>((count >> 16) & 0xFF));
+ out.push_back(static_cast<u8>((count >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(count & 0xFF));
+}
+
+void MsgPack::Writer::WriteKey(std::string_view s) {
+ WriteString(s);
+}
+
+void MsgPack::Writer::WriteString(std::string_view s) {
+ if (s.size() <= 31) {
+ out.push_back(static_cast<u8>(0xA0 | s.size()));
+ } else if (s.size() <= 0xFF) {
+ out.push_back(0xD9);
+ out.push_back(static_cast<u8>(s.size()));
+ } else if (s.size() <= 0xFFFF) {
+ out.push_back(0xDA);
+ out.push_back(static_cast<u8>((s.size() >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(s.size() & 0xFF));
+ } else {
+ out.push_back(0xDB);
+ out.push_back(static_cast<u8>((s.size() >> 24) & 0xFF));
+ out.push_back(static_cast<u8>((s.size() >> 16) & 0xFF));
+ out.push_back(static_cast<u8>((s.size() >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(s.size() & 0xFF));
+ }
+ WriteBytes({reinterpret_cast<const u8*>(s.data()), s.size()});
+}
+
+void MsgPack::Writer::WriteInt64(s64 v) {
+ if (v >= 0) {
+ WriteUInt(static_cast<u64>(v));
+ return;
+ }
+ if (v >= -32) {
+ out.push_back(static_cast<u8>(0xE0 | (v + 32)));
+ } else if (v >= -128) {
+ out.push_back(0xD0);
+ out.push_back(static_cast<u8>(v & 0xFF));
+ } else if (v >= -32768) {
+ out.push_back(0xD1);
+ out.push_back(static_cast<u8>((v >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(v & 0xFF));
+ } else if (v >= INT32_MIN) {
+ out.push_back(0xD2);
+ out.push_back(static_cast<u8>((v >> 24) & 0xFF));
+ out.push_back(static_cast<u8>((v >> 16) & 0xFF));
+ out.push_back(static_cast<u8>((v >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(v & 0xFF));
+ } else {
+ out.push_back(0xD3);
+ for (int i = 7; i >= 0; --i) {
+ out.push_back(static_cast<u8>((static_cast<u64>(v) >> (8 * i)) & 0xFF));
+ }
+ }
+}
+
+void MsgPack::Writer::WriteUInt(u64 v) {
+ if (v < 0x80) {
+ out.push_back(static_cast<u8>(v));
+ } else if (v <= 0xFF) {
+ out.push_back(0xCC);
+ out.push_back(static_cast<u8>(v));
+ } else if (v <= 0xFFFF) {
+ out.push_back(0xCD);
+ out.push_back(static_cast<u8>((v >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(v & 0xFF));
+ } else if (v <= 0xFFFFFFFFULL) {
+ out.push_back(0xCE);
+ out.push_back(static_cast<u8>((v >> 24) & 0xFF));
+ out.push_back(static_cast<u8>((v >> 16) & 0xFF));
+ out.push_back(static_cast<u8>((v >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(v & 0xFF));
+ } else {
+ out.push_back(0xCF);
+ for (int i = 7; i >= 0; --i) {
+ out.push_back(static_cast<u8>((v >> (8 * i)) & 0xFF));
+ }
+ }
+}
+
+void MsgPack::Writer::WriteNil() {
+ out.push_back(0xC0);
+}
+
+void MsgPack::Writer::WriteFixArray(size_t count) {
+ if (count <= 15) {
+ out.push_back(static_cast<u8>(0x90 | count));
+ } else if (count <= 0xFFFF) {
+ out.push_back(0xDC);
+ out.push_back(static_cast<u8>((count >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(count & 0xFF));
+ } else {
+ out.push_back(0xDD);
+ out.push_back(static_cast<u8>((count >> 24) & 0xFF));
+ out.push_back(static_cast<u8>((count >> 16) & 0xFF));
+ out.push_back(static_cast<u8>((count >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(count & 0xFF));
+ }
+}
+
+void MsgPack::Writer::WriteBinary(const std::vector<u8>& data) {
+ if (data.size() <= 0xFF) {
+ out.push_back(0xC4);
+ out.push_back(static_cast<u8>(data.size()));
+ } else if (data.size() <= 0xFFFF) {
+ out.push_back(0xC5);
+ out.push_back(static_cast<u8>((data.size() >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(data.size() & 0xFF));
+ } else {
+ out.push_back(0xC6);
+ out.push_back(static_cast<u8>((data.size() >> 24) & 0xFF));
+ out.push_back(static_cast<u8>((data.size() >> 16) & 0xFF));
+ out.push_back(static_cast<u8>((data.size() >> 8) & 0xFF));
+ out.push_back(static_cast<u8>(data.size() & 0xFF));
+ }
+ WriteBytes(data);
+}
+
+void MsgPack::Writer::WriteBool(bool v) {
+ out.push_back(v ? 0xC3 : 0xC2);
+}
+
+std::vector<u8> MsgPack::Writer::Take() {
+ return std::move(out);
+}
+
+MsgPack::Reader::Reader(std::span<const u8> buffer) : data(buffer) {}
+
+bool MsgPack::Reader::Fail(const char* msg) {
+ error = msg;
+ return false;
+}
+
+bool MsgPack::Reader::Ensure(size_t n) const {
+ return offset + n <= data.size();
+}
+
+u8 MsgPack::Reader::Peek() const {
+ return Ensure(1) ? data[offset] : 0;
+}
+
+u8 MsgPack::Reader::ReadByte() {
+ if (!Ensure(1)) {
+ return 0;
+ }
+ return data[offset++];
+}
+
+bool MsgPack::Reader::ReadSize(size_t byte_count, size_t& out_size) {
+ if (!Ensure(byte_count)) {
+ return Fail("size out of range");
+ }
+ size_t value = 0;
+ for (size_t i = 0; i < byte_count; ++i) {
+ value = (value << 8) | data[offset + i];
+ }
+ offset += byte_count;
+ out_size = value;
+ return true;
+}
+
+bool MsgPack::Reader::SkipBytes(size_t n) {
+ if (!Ensure(n)) {
+ return Fail("skip out of range");
+ }
+ offset += n;
+ return true;
+}
+
+bool MsgPack::Reader::SkipValue() {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = ReadByte();
+
+ if (byte <= 0x7F || (byte >= 0xE0)) {
+ return true;
+ }
+ if ((byte & 0xE0) == 0xA0) {
+ const size_t len = byte & 0x1F;
+ return SkipBytes(len);
+ }
+ if ((byte & 0xF0) == 0x80) {
+ const size_t count = byte & 0x0F;
+ return SkipContainer(count * 2, true);
+ }
+ if ((byte & 0xF0) == 0x90) {
+ const size_t count = byte & 0x0F;
+ return SkipContainer(count, false);
+ }
+
+ switch (byte) {
+ case 0xC0:
+ case 0xC2:
+ case 0xC3:
+ return true;
+ case 0xC4:
+ case 0xC5:
+ case 0xC6: {
+ size_t size = 0;
+ if (!ReadSize(static_cast<size_t>(1) << (byte - 0xC4), size)) {
+ return false;
+ }
+ return SkipBytes(size);
+ }
+ case 0xC7:
+ case 0xC8:
+ case 0xC9:
+ return Fail("ext not supported");
+ case 0xCA:
+ return SkipBytes(4);
+ case 0xCB:
+ return SkipBytes(8);
+ case 0xCC:
+ return SkipBytes(1);
+ case 0xCD:
+ return SkipBytes(2);
+ case 0xCE:
+ return SkipBytes(4);
+ case 0xCF:
+ return SkipBytes(8);
+ case 0xD0:
+ return SkipBytes(1);
+ case 0xD1:
+ return SkipBytes(2);
+ case 0xD2:
+ return SkipBytes(4);
+ case 0xD3:
+ return SkipBytes(8);
+ case 0xD4:
+ case 0xD5:
+ case 0xD6:
+ case 0xD7:
+ case 0xD8:
+ return Fail("fixext not supported");
+ case 0xD9: {
+ size_t size = 0;
+ if (!ReadSize(1, size)) {
+ return false;
+ }
+ return SkipBytes(size);
+ }
+ case 0xDA: {
+ size_t size = 0;
+ if (!ReadSize(2, size)) {
+ return false;
+ }
+ return SkipBytes(size);
+ }
+ case 0xDB: {
+ size_t size = 0;
+ if (!ReadSize(4, size)) {
+ return false;
+ }
+ return SkipBytes(size);
+ }
+ case 0xDC: {
+ size_t count = 0;
+ if (!ReadSize(2, count)) {
+ return false;
+ }
+ return SkipContainer(count, false);
+ }
+ case 0xDD: {
+ size_t count = 0;
+ if (!ReadSize(4, count)) {
+ return false;
+ }
+ return SkipContainer(count, false);
+ }
+ case 0xDE: {
+ size_t count = 0;
+ if (!ReadSize(2, count)) {
+ return false;
+ }
+ return SkipContainer(count * 2, true);
+ }
+ case 0xDF: {
+ size_t count = 0;
+ if (!ReadSize(4, count)) {
+ return false;
+ }
+ return SkipContainer(count * 2, true);
+ }
+ default:
+ return Fail("unknown type");
+ }
+}
+
+bool MsgPack::Reader::SkipContainer(size_t count, bool /*map_mode*/) {
+ for (size_t i = 0; i < count; ++i) {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::SkipAll() {
+ while (!End()) {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadMapHeader(size_t& count) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = Peek();
+ if ((byte & 0xF0) == 0x80) {
+ count = byte & 0x0F;
+ offset++;
+ return true;
+ }
+ if (byte == 0xDE) {
+ ReadByte();
+ return ReadSize(2, count);
+ }
+ if (byte == 0xDF) {
+ ReadByte();
+ return ReadSize(4, count);
+ }
+ return Fail("not a map");
+}
+
+bool MsgPack::Reader::ReadArrayHeader(size_t& count) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = Peek();
+ if ((byte & 0xF0) == 0x90) {
+ count = byte & 0x0F;
+ offset++;
+ return true;
+ }
+ if (byte == 0xDC) {
+ ReadByte();
+ return ReadSize(2, count);
+ }
+ if (byte == 0xDD) {
+ ReadByte();
+ return ReadSize(4, count);
+ }
+ return Fail("not an array");
+}
+
+bool MsgPack::Reader::ReadUInt(u64& value) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = Peek();
+ if (byte <= 0x7F) {
+ value = byte;
+ offset++;
+ return true;
+ }
+ if (byte == 0xCC) {
+ ReadByte();
+ if (!Ensure(1)) {
+ return Fail("uint8 truncated");
+ }
+ value = data[offset++];
+ return true;
+ }
+ if (byte == 0xCD) {
+ ReadByte();
+ size_t tmp = 0;
+ if (!ReadSize(2, tmp)) {
+ return false;
+ }
+ value = tmp;
+ return true;
+ }
+ if (byte == 0xCE) {
+ ReadByte();
+ size_t tmp = 0;
+ if (!ReadSize(4, tmp)) {
+ return false;
+ }
+ value = tmp;
+ return true;
+ }
+ if (byte == 0xCF) {
+ ReadByte();
+ if (!Ensure(8)) {
+ return Fail("uint64 truncated");
+ }
+ u64 tmp = 0;
+ for (int i = 0; i < 8; ++i) {
+ tmp = (tmp << 8) | data[offset + i];
+ }
+ offset += 8;
+ value = tmp;
+ return true;
+ }
+ return Fail("not uint");
+}
+
+bool MsgPack::Reader::ReadInt(s64& value) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = Peek();
+ if (byte <= 0x7F) {
+ value = byte;
+ offset++;
+ return true;
+ }
+ if (byte >= 0xE0) {
+ value = static_cast<int8_t>(byte);
+ offset++;
+ return true;
+ }
+ if (byte == 0xD0) {
+ ReadByte();
+ if (!Ensure(1)) {
+ return Fail("int8 truncated");
+ }
+ value = static_cast<int8_t>(data[offset]);
+ offset += 1;
+ return true;
+ }
+ if (byte == 0xD1) {
+ ReadByte();
+ if (!Ensure(2)) {
+ return Fail("int16 truncated");
+ }
+ value = static_cast<int16_t>((data[offset] << 8) | data[offset + 1]);
+ offset += 2;
+ return true;
+ }
+ if (byte == 0xD2) {
+ ReadByte();
+ if (!Ensure(4)) {
+ return Fail("int32 truncated");
+ }
+ const s32 tmp = static_cast<int32_t>((data[offset] << 24) | (data[offset + 1] << 16) |
+ (data[offset + 2] << 8) | data[offset + 3]);
+ offset += 4;
+ value = tmp;
+ return true;
+ }
+ if (byte == 0xD3) {
+ ReadByte();
+ if (!Ensure(8)) {
+ return Fail("int64 truncated");
+ }
+ s64 tmp = 0;
+ for (int i = 0; i < 8; ++i) {
+ tmp = (tmp << 8) | data[offset + i];
+ }
+ offset += 8;
+ value = tmp;
+ return true;
+ }
+ return Fail("not int");
+}
+
+bool MsgPack::Reader::ReadBool(bool& value) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = ReadByte();
+ if (byte == 0xC2) {
+ value = false;
+ return true;
+ }
+ if (byte == 0xC3) {
+ value = true;
+ return true;
+ }
+ return Fail("not bool");
+}
+
+bool MsgPack::Reader::ReadString(std::string& value) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = ReadByte();
+ size_t len = 0;
+ if ((byte & 0xE0) == 0xA0) {
+ len = byte & 0x1F;
+ } else if (byte == 0xD9) {
+ if (!ReadSize(1, len)) {
+ return false;
+ }
+ } else if (byte == 0xDA) {
+ if (!ReadSize(2, len)) {
+ return false;
+ }
+ } else if (byte == 0xDB) {
+ if (!ReadSize(4, len)) {
+ return false;
+ }
+ } else {
+ return Fail("not string");
+ }
+
+ if (!Ensure(len)) {
+ return Fail("string truncated");
+ }
+ value.assign(reinterpret_cast<const char*>(data.data() + offset), len);
+ offset += len;
+ return true;
+}
+
+bool MsgPack::Reader::ReadBinary(std::vector<u8>& value) {
+ if (End()) {
+ return Fail("unexpected end");
+ }
+ const u8 byte = ReadByte();
+ size_t len = 0;
+ if (byte == 0xC4) {
+ if (!ReadSize(1, len)) {
+ return false;
+ }
+ } else if (byte == 0xC5) {
+ if (!ReadSize(2, len)) {
+ return false;
+ }
+ } else if (byte == 0xC6) {
+ if (!ReadSize(4, len)) {
+ return false;
+ }
+ } else {
+ return Fail("not binary");
+ }
+ if (!Ensure(len)) {
+ return Fail("binary truncated");
+ }
+ value.assign(data.begin() + offset, data.begin() + offset + len);
+ offset += len;
+ return true;
+}
+
+bool MsgPack::Reader::ReadBinaryCompat(std::vector<u8>& value) {
+ if (!ReadBinary(value)) {
+ value.clear();
+ return false;
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadStringArray(std::vector<std::string>& out) {
+ size_t count = 0;
+ if (!ReadArrayHeader(count)) {
+ return false;
+ }
+ out.clear();
+ out.reserve(count);
+ for (size_t i = 0; i < count; ++i) {
+ std::string value;
+ if (!ReadString(value)) {
+ return false;
+ }
+ out.push_back(std::move(value));
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsVersion(NewsStruct::Version& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+ if (key == "format") {
+ if (!ReadUInt(out.format)) {
+ return false;
+ }
+ } else if (key == "semantics") {
+ if (!ReadUInt(out.semantics)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsSubject(NewsStruct::Subject& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+ if (key == "caption") {
+ if (!ReadUInt(out.caption)) {
+ return false;
+ }
+ } else if (key == "text") {
+ if (!ReadString(out.text)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsFooter(NewsStruct::Footer& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+ if (key == "text") {
+ if (!ReadString(out.text)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadByteArray(std::vector<u8>& out) {
+ if (!ReadBinary(out)) {
+ out.clear();
+ return false;
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsBody(NewsStruct::Body& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+ if (key == "text") {
+ if (!ReadString(out.text)) {
+ return false;
+ }
+ } else if (key == "main_image_height") {
+ if (!ReadUInt(out.main_image_height)) {
+ return false;
+ }
+ } else if (key == "movie_url") {
+ if (!ReadString(out.movie_url)) {
+ return false;
+ }
+ } else if (key == "main_image") {
+ if (!ReadByteArray(out.main_image)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsBrowser(NewsStruct::More::Browser& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ out.present = true;
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+ if (key == "url") {
+ if (!ReadString(out.url)) {
+ return false;
+ }
+ } else if (key == "text") {
+ if (!ReadString(out.text)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsMore(NewsStruct::More& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ out.has_browser = false;
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+ if (key == "browser") {
+ out.has_browser = true;
+ if (!ReadNewsBrowser(out.browser)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+bool MsgPack::Reader::ReadNewsStruct(NewsStruct& out) {
+ size_t count = 0;
+ if (!ReadMapHeader(count)) {
+ return false;
+ }
+ for (size_t i = 0; i < count; ++i) {
+ std::string key;
+ if (!ReadString(key)) {
+ return false;
+ }
+
+ auto read_u64 = [&](u64& target) -> bool {
+ return ReadUInt(target);
+ };
+
+ if (key == "version") {
+ if (!ReadNewsVersion(out.version)) {
+ return false;
+ }
+ } else if (key == "news_id") {
+ if (!read_u64(out.news_id)) {
+ return false;
+ }
+ } else if (key == "published_at") {
+ if (!read_u64(out.published_at)) {
+ return false;
+ }
+ } else if (key == "pickup_limit") {
+ if (!read_u64(out.pickup_limit)) {
+ return false;
+ }
+ } else if (key == "priority") {
+ if (!read_u64(out.priority)) {
+ return false;
+ }
+ } else if (key == "deletion_priority") {
+ if (!read_u64(out.deletion_priority)) {
+ return false;
+ }
+ } else if (key == "language") {
+ if (!ReadString(out.language)) {
+ return false;
+ }
+ } else if (key == "supported_languages") {
+ if (!ReadStringArray(out.supported_languages)) {
+ return false;
+ }
+ } else if (key == "display_type") {
+ if (!ReadString(out.display_type)) {
+ return false;
+ }
+ } else if (key == "topic_id") {
+ if (!ReadString(out.topic_id)) {
+ return false;
+ }
+ } else if (key == "no_photography") {
+ if (!read_u64(out.no_photography)) {
+ return false;
+ }
+ } else if (key == "surprise") {
+ if (!read_u64(out.surprise)) {
+ return false;
+ }
+ } else if (key == "bashotorya") {
+ if (!read_u64(out.bashotorya)) {
+ return false;
+ }
+ } else if (key == "movie") {
+ if (!read_u64(out.movie)) {
+ return false;
+ }
+ } else if (key == "subject") {
+ if (!ReadNewsSubject(out.subject)) {
+ return false;
+ }
+ } else if (key == "topic_name") {
+ if (!ReadString(out.topic_name)) {
+ return false;
+ }
+ } else if (key == "list_image") {
+ if (!ReadByteArray(out.list_image)) {
+ return false;
+ }
+ } else if (key == "footer") {
+ if (!ReadNewsFooter(out.footer)) {
+ return false;
+ }
+ } else if (key == "allow_domains") {
+ if (!ReadString(out.allow_domains)) {
+ return false;
+ }
+ } else if (key == "more") {
+ if (!ReadNewsMore(out.more)) {
+ return false;
+ }
+ } else if (key == "body") {
+ if (!ReadNewsBody(out.body)) {
+ return false;
+ }
+ } else if (key == "contents_descriptors") {
+ if (!ReadString(out.contents_descriptors)) {
+ return false;
+ }
+ } else if (key == "interactive_elements") {
+ if (!ReadString(out.interactive_elements)) {
+ return false;
+ }
+ } else {
+ if (!SkipValue()) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/msgpack.h b/src/core/hle/service/bcat/news/msgpack.h
new file mode 100644
index 0000000000..a7a2aa40ba
--- /dev/null
+++ b/src/core/hle/service/bcat/news/msgpack.h
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <span>
+#include <string>
+#include <string_view>
+#include <type_traits>
+#include <vector>
+
+#include "common/common_types.h"
+
+namespace Service::News {
+
+struct NewsStruct {
+ struct Version {
+ u64 format{};
+ u64 semantics{};
+ };
+
+ struct Subject {
+ u64 caption{};
+ std::string text;
+ };
+
+ struct Footer {
+ std::string text;
+ };
+
+ struct Body {
+ std::string text;
+ u64 main_image_height{};
+ std::string movie_url;
+ std::vector<u8> main_image;
+ };
+
+ struct More {
+ struct Browser {
+ std::string url;
+ std::string text;
+ bool present{};
+ } browser;
+ bool has_browser{};
+ };
+
+ Version version;
+ u64 news_id{};
+ u64 published_at{};
+ u64 pickup_limit{};
+ u64 priority{};
+ u64 deletion_priority{};
+ std::string language;
+ std::vector<std::string> supported_languages;
+ std::string display_type;
+ std::string topic_id;
+ u64 no_photography{};
+ u64 surprise{};
+ u64 bashotorya{};
+ u64 movie{};
+ Subject subject;
+ std::string topic_name;
+ std::vector<u8> list_image;
+ Footer footer;
+ std::string allow_domains;
+ More more;
+ Body body;
+ std::string contents_descriptors;
+ std::string interactive_elements;
+};
+
+class MsgPack {
+public:
+ class Writer {
+ public:
+ void WriteFixMap(size_t count);
+ void WriteMap32(size_t count);
+ void WriteKey(std::string_view key);
+ void WriteString(std::string_view value);
+ void WriteInt64(s64 value);
+ void WriteUInt(u64 value);
+ void WriteNil();
+ void WriteFixArray(size_t count);
+ void WriteBinary(const std::vector<u8>& data);
+ void WriteBool(bool value);
+
+ std::vector<u8> Take();
+ const std::vector<u8>& Buffer() const { return out; }
+ void Clear() { out.clear(); }
+
+ private:
+ void WriteBytes(std::span<const u8> bytes);
+
+ std::vector<u8> out;
+ };
+
+ class Reader {
+ public:
+ explicit Reader(std::span<const u8> buffer);
+
+ template <typename T>
+ bool Read(T& out) {
+ if constexpr (std::is_same_v<T, NewsStruct>) {
+ return ReadNewsStruct(out);
+ } else {
+ static_assert(sizeof(T) == 0, "Unsupported MsgPack::Reader::Read type");
+ }
+ }
+
+ bool SkipValue();
+ bool SkipAll();
+
+ bool ReadMapHeader(size_t& count);
+ bool ReadArrayHeader(size_t& count);
+ bool ReadUInt(u64& value);
+ bool ReadInt(s64& value);
+ bool ReadBool(bool& value);
+ bool ReadString(std::string& value);
+ bool ReadBinary(std::vector<u8>& value);
+
+ bool End() const { return offset >= data.size(); }
+ std::string_view Error() const { return error; }
+
+ private:
+ bool Fail(const char* msg);
+ bool Ensure(size_t n) const;
+ u8 Peek() const;
+ u8 ReadByte();
+ bool ReadSize(size_t byte_count, size_t& out_size);
+ bool SkipBytes(size_t n);
+ bool SkipContainer(size_t count, bool map_mode);
+ bool ReadNewsStruct(NewsStruct& out);
+ bool ReadNewsVersion(NewsStruct::Version& out);
+ bool ReadNewsSubject(NewsStruct::Subject& out);
+ bool ReadNewsFooter(NewsStruct::Footer& out);
+ bool ReadNewsBody(NewsStruct::Body& out);
+ bool ReadNewsMore(NewsStruct::More& out);
+ bool ReadNewsBrowser(NewsStruct::More::Browser& out);
+ bool ReadStringArray(std::vector<std::string>& out);
+ bool ReadBinaryCompat(std::vector<u8>& out);
+ bool ReadByteArray(std::vector<u8>& out);
+
+ std::span<const u8> data;
+ size_t offset{0};
+ std::string error;
+ };
+};
+
+} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_data_service.cpp b/src/core/hle/service/bcat/news/news_data_service.cpp
index 08103c9c3b..72055fc66a 100644
--- a/src/core/hle/service/bcat/news/news_data_service.cpp
+++ b/src/core/hle/service/bcat/news/news_data_service.cpp
@@ -1,25 +1,125 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "core/hle/service/bcat/news/news_data_service.h"
+#include "core/hle/service/bcat/news/builtin_news.h"
+#include "core/hle/service/bcat/news/news_storage.h"
+#include "core/hle/service/cmif_serialization.h"
+
+#include "common/logging/log.h"
+
+#include <cstring>
namespace Service::News {
+namespace {
+
+std::string_view ToStringView(std::span<const char> buf) {
+ const std::string_view sv{buf.data(), buf.size()};
+ const auto nul = sv.find('\0');
+ return nul == std::string_view::npos ? sv : sv.substr(0, nul);
+}
+
+} // namespace
INewsDataService::INewsDataService(Core::System& system_)
: ServiceFramework{system_, "INewsDataService"} {
- // clang-format off
static const FunctionInfo functions[] = {
- {0, nullptr, "Open"},
- {1, nullptr, "OpenWithNewsRecordV1"},
- {2, nullptr, "Read"},
- {3, nullptr, "GetSize"},
- {1001, nullptr, "OpenWithNewsRecord"},
+ {0, D<&INewsDataService::Open>, "Open"},
+ {1, D<&INewsDataService::OpenWithNewsRecordV1>, "OpenWithNewsRecordV1"},
+ {2, D<&INewsDataService::Read>, "Read"},
+ {3, D<&INewsDataService::GetSize>, "GetSize"},
+ {1001, D<&INewsDataService::OpenWithNewsRecord>, "OpenWithNewsRecord"},
};
- // clang-format on
-
RegisterHandlers(functions);
}
INewsDataService::~INewsDataService() = default;
+bool INewsDataService::TryOpen(std::string_view key, std::string_view user) {
+ opened_payload.clear();
+
+ if (auto found = NewsStorage::Instance().FindByNewsId(key, user)) {
+ opened_payload = std::move(found->payload);
+ return true;
+ }
+
+ if (!user.empty()) {
+ if (auto found = NewsStorage::Instance().FindByNewsId(key)) {
+ opened_payload = std::move(found->payload);
+ return true;
+ }
+ }
+
+ const auto list = NewsStorage::Instance().ListAll();
+ if (!list.empty()) {
+ if (auto found = NewsStorage::Instance().FindByNewsId(ToStringView(list.front().news_id))) {
+ opened_payload = std::move(found->payload);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+Result INewsDataService::Open(InBuffer<BufferAttr_HipcMapAlias> name) {
+ EnsureBuiltinNewsLoaded();
+
+ const auto key = ToStringView({reinterpret_cast<const char*>(name.data()), name.size()});
+
+ if (TryOpen(key, {})) {
+ R_SUCCEED();
+ }
+
+ R_RETURN(ResultUnknown);
+}
+
+Result INewsDataService::OpenWithNewsRecordV1(NewsRecordV1 record) {
+ EnsureBuiltinNewsLoaded();
+
+ const auto key = ToStringView(record.news_id);
+ const auto user = ToStringView(record.user_id);
+
+ if (TryOpen(key, user)) {
+ R_SUCCEED();
+ }
+
+ R_RETURN(ResultUnknown);
+}
+
+Result INewsDataService::OpenWithNewsRecord(NewsRecord record) {
+ EnsureBuiltinNewsLoaded();
+
+ const auto key = ToStringView(record.news_id);
+ const auto user = ToStringView(record.user_id);
+
+ if (TryOpen(key, user)) {
+ R_SUCCEED();
+ }
+
+ R_RETURN(ResultUnknown);
+}
+
+Result INewsDataService::Read(Out<u64> out_size, s64 offset,
+ OutBuffer<BufferAttr_HipcMapAlias> out_buffer) {
+ const auto off = static_cast<size_t>(std::max<s64>(0, offset));
+
+ if (off >= opened_payload.size()) {
+ *out_size = 0;
+ R_SUCCEED();
+ }
+
+ const size_t len = std::min(out_buffer.size(), opened_payload.size() - off);
+ std::memcpy(out_buffer.data(), opened_payload.data() + off, len);
+ *out_size = len;
+ R_SUCCEED();
+}
+
+Result INewsDataService::GetSize(Out<s64> out_size) {
+ *out_size = static_cast<s64>(opened_payload.size());
+ R_SUCCEED();
+}
+
} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_data_service.h b/src/core/hle/service/bcat/news/news_data_service.h
index 12082ada41..8f5e031701 100644
--- a/src/core/hle/service/bcat/news/news_data_service.h
+++ b/src/core/hle/service/bcat/news/news_data_service.h
@@ -1,10 +1,16 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
+#include "core/hle/service/cmif_types.h"
#include "core/hle/service/service.h"
+#include "core/hle/service/bcat/news/news_storage.h"
+
namespace Core {
class System;
}
@@ -15,6 +21,17 @@ class INewsDataService final : public ServiceFramework<INewsDataService> {
public:
explicit INewsDataService(Core::System& system_);
~INewsDataService() override;
+
+private:
+ bool TryOpen(std::string_view key, std::string_view user);
+
+ Result Open(InBuffer<BufferAttr_HipcMapAlias> name);
+ Result OpenWithNewsRecordV1(NewsRecordV1 record_buffer);
+ Result OpenWithNewsRecord(NewsRecord record_buffer);
+ Result Read(Out<u64> out_size, s64 offset, OutBuffer<BufferAttr_HipcMapAlias> out_buffer);
+ Result GetSize(Out<s64> out_size);
+
+ std::vector<u8> opened_payload;
};
} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_database_service.cpp b/src/core/hle/service/bcat/news/news_database_service.cpp
index b94ef0636f..f2f8a3286a 100644
--- a/src/core/hle/service/bcat/news/news_database_service.cpp
+++ b/src/core/hle/service/bcat/news/news_database_service.cpp
@@ -1,52 +1,194 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "core/hle/service/bcat/news/news_database_service.h"
+#include "core/hle/service/bcat/news/builtin_news.h"
+#include "core/hle/service/bcat/news/news_storage.h"
#include "core/hle/service/cmif_serialization.h"
+#include <algorithm>
+#include <cstring>
+
namespace Service::News {
+namespace {
+
+std::string_view ToStringView(std::span<const u8> buf) {
+ if (buf.empty()) return {};
+ auto data = reinterpret_cast<const char*>(buf.data());
+ return {data, strnlen(data, buf.size())};
+}
+
+std::string_view ToStringView(std::span<const char> buf) {
+ if (buf.empty()) return {};
+ return {buf.data(), strnlen(buf.data(), buf.size())};
+}
+
+bool UpdateField(NewsRecord& rec, std::string_view column, s32 value, bool additive) {
+ auto apply = [&](s32& field) {
+ field = additive ? field + value : value;
+ return true;
+ };
+
+ if (column == "read") return apply(rec.read);
+ if (column == "newly") return apply(rec.newly);
+ if (column == "displayed") return apply(rec.displayed);
+ if (column == "extra1" || column == "extra_1") return apply(rec.extra1);
+ if (column == "extra2" || column == "extra_2") return apply(rec.extra2);
+
+ // Accept but ignore fields that don't exist in our struct
+ return column == "priority" || column == "decoration_type" ||
+ column == "feedback" || column == "category";
+}
+
+} // namespace
INewsDatabaseService::INewsDatabaseService(Core::System& system_)
: ServiceFramework{system_, "INewsDatabaseService"} {
- // clang-format off
static const FunctionInfo functions[] = {
- {0, nullptr, "GetListV1"},
+ {0, D<&INewsDatabaseService::GetListV1>, "GetListV1"},
{1, D<&INewsDatabaseService::Count>, "Count"},
- {2, nullptr, "CountWithKey"},
- {3, nullptr, "UpdateIntegerValue"},
+ {2, D<&INewsDatabaseService::CountWithKey>, "CountWithKey"},
+ {3, D<&INewsDatabaseService::UpdateIntegerValue>, "UpdateIntegerValue"},
{4, D<&INewsDatabaseService::UpdateIntegerValueWithAddition>, "UpdateIntegerValueWithAddition"},
- {5, nullptr, "UpdateStringValue"},
+ {5, D<&INewsDatabaseService::UpdateStringValue>, "UpdateStringValue"},
{1000, D<&INewsDatabaseService::GetList>, "GetList"},
};
- // clang-format on
-
RegisterHandlers(functions);
}
INewsDatabaseService::~INewsDatabaseService() = default;
-Result INewsDatabaseService::Count(Out<s32> out_count,
- InBuffer<BufferAttr_HipcPointer> buffer_data) {
- LOG_WARNING(Service_BCAT, "(STUBBED) called, buffer_size={}", buffer_data.size());
- *out_count = 0;
+Result INewsDatabaseService::Count(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> where) {
+ EnsureBuiltinNewsLoaded();
+ *out_count = static_cast<s32>(NewsStorage::Instance().ListAll().size());
+ R_SUCCEED();
+}
+
+Result INewsDatabaseService::CountWithKey(Out<s32> out_count,
+ InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> where) {
+ EnsureBuiltinNewsLoaded();
+ *out_count = static_cast<s32>(NewsStorage::Instance().ListAll().size());
R_SUCCEED();
}
-Result INewsDatabaseService::UpdateIntegerValueWithAddition(
- u32 value, InBuffer<BufferAttr_HipcPointer> buffer_data_1,
- InBuffer<BufferAttr_HipcPointer> buffer_data_2) {
- LOG_WARNING(Service_BCAT, "(STUBBED) called, value={}, buffer_size_1={}, buffer_data_2={}",
- value, buffer_data_1.size(), buffer_data_2.size());
+Result INewsDatabaseService::UpdateIntegerValue(u32 value,
+ InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> where) {
+ const auto column = ToStringView(key);
+ for (const auto& rec : NewsStorage::Instance().ListAll()) {
+ NewsStorage::Instance().UpdateRecord(
+ ToStringView(rec.news_id), {},
+ [&](NewsRecord& r) { UpdateField(r, column, static_cast<s32>(value), false); });
+ }
R_SUCCEED();
}
-Result INewsDatabaseService::GetList(Out<s32> out_count, u32 value,
- OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data,
- InBuffer<BufferAttr_HipcPointer> buffer_data_1,
- InBuffer<BufferAttr_HipcPointer> buffer_data_2) {
- LOG_WARNING(Service_BCAT, "(STUBBED) called, value={}, buffer_size_1={}, buffer_data_2={}",
- value, buffer_data_1.size(), buffer_data_2.size());
- *out_count = 0;
+Result INewsDatabaseService::UpdateIntegerValueWithAddition(u32 value,
+ InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> where) {
+ const auto column = ToStringView(key);
+ const auto where_str = ToStringView(where);
+
+ // Extract news_id from where clause like "N_SWITCH(news_id,'LA00000000000123456',1,0)=1"
+ auto extract_news_id = [](std::string_view w) -> std::string {
+ auto pos = w.find("'LA");
+ if (pos == std::string_view::npos) return {};
+ pos++; // skip the '
+ auto end = w.find("'", pos);
+ if (end == std::string_view::npos) return {};
+ return std::string(w.substr(pos, end - pos));
+ };
+
+ const auto news_id = extract_news_id(where_str);
+
+ if (column == "read" && value > 0 && !news_id.empty()) {
+ NewsStorage::Instance().MarkAsRead(news_id);
+ } else if (!news_id.empty()) {
+ NewsStorage::Instance().UpdateRecord(news_id, {},
+ [&](NewsRecord& r) { UpdateField(r, column, static_cast<s32>(value), true); });
+ }
+
+ R_SUCCEED();
+}
+
+Result INewsDatabaseService::UpdateStringValue(InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> value,
+ InBuffer<BufferAttr_HipcPointer> where) {
+ LOG_WARNING(Service_BCAT, "(STUBBED) UpdateStringValue");
+ R_SUCCEED();
+}
+
+Result INewsDatabaseService::GetListV1(Out<s32> out_count,
+ OutBuffer<BufferAttr_HipcMapAlias> out_buffer,
+ InBuffer<BufferAttr_HipcPointer> where,
+ InBuffer<BufferAttr_HipcPointer> order,
+ s32 offset) {
+ EnsureBuiltinNewsLoaded();
+
+ auto record_size = sizeof(NewsRecordV1);
+
+ if (out_buffer.size() < record_size) {
+ *out_count = 0;
+ R_SUCCEED();
+ }
+
+ std::memset(out_buffer.data(), 0, out_buffer.size());
+
+ const auto list = NewsStorage::Instance().ListAll();
+ const size_t start = static_cast<size_t>(std::max(0, offset));
+ const size_t max_records = out_buffer.size() / record_size;
+ const size_t available = start < list.size() ? list.size() - start : 0;
+ const size_t count = std::min(max_records, available);
+
+ for (size_t i = 0; i < count; ++i) {
+ const auto& src = list[start + i];
+ NewsRecordV1 dst{};
+ std::memcpy(dst.news_id.data(), src.news_id.data(), dst.news_id.size());
+ std::memcpy(dst.user_id.data(), src.user_id.data(), dst.user_id.size());
+ dst.received_time = src.received_time;
+ dst.read = src.read;
+ dst.newly = src.newly;
+ dst.displayed = src.displayed;
+ dst.extra1 = src.extra1;
+ std::memcpy(out_buffer.data() + i * record_size, &dst, record_size);
+ }
+
+ *out_count = static_cast<s32>(count);
+ R_SUCCEED();
+}
+
+Result INewsDatabaseService::GetList(Out<s32> out_count,
+ OutBuffer<BufferAttr_HipcMapAlias> out_buffer,
+ InBuffer<BufferAttr_HipcPointer> where,
+ InBuffer<BufferAttr_HipcPointer> order,
+ s32 offset) {
+ EnsureBuiltinNewsLoaded();
+ NewsStorage::Instance().ResetOpenCounter();
+
+ auto record_size = sizeof(NewsRecord);
+
+ if (out_buffer.size() < record_size) {
+ *out_count = 0;
+ R_SUCCEED();
+ }
+
+ std::memset(out_buffer.data(), 0, out_buffer.size());
+
+ const auto list = NewsStorage::Instance().ListAll();
+ const size_t start = static_cast<size_t>(std::max(0, offset));
+ const size_t max_records = out_buffer.size() / record_size;
+ const size_t available = start < list.size() ? list.size() - start : 0;
+ const size_t count = std::min(max_records, available);
+
+ if (count > 0) {
+ std::memcpy(out_buffer.data(), list.data() + start, count * record_size);
+ }
+
+ *out_count = static_cast<s32>(count);
R_SUCCEED();
}
diff --git a/src/core/hle/service/bcat/news/news_database_service.h b/src/core/hle/service/bcat/news/news_database_service.h
index 860b7074ca..143864e80e 100644
--- a/src/core/hle/service/bcat/news/news_database_service.h
+++ b/src/core/hle/service/bcat/news/news_database_service.h
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -6,6 +9,8 @@
#include "core/hle/service/cmif_types.h"
#include "core/hle/service/service.h"
+#include "core/hle/service/bcat/news/news_storage.h"
+
namespace Core {
class System;
}
@@ -20,13 +25,30 @@ public:
private:
Result Count(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> buffer_data);
+ Result CountWithKey(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> where);
+
+ Result UpdateIntegerValue(u32 value, InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> where);
+
Result UpdateIntegerValueWithAddition(u32 value, InBuffer<BufferAttr_HipcPointer> buffer_data_1,
InBuffer<BufferAttr_HipcPointer> buffer_data_2);
- Result GetList(Out<s32> out_count, u32 value,
+ Result UpdateStringValue(InBuffer<BufferAttr_HipcPointer> key,
+ InBuffer<BufferAttr_HipcPointer> value,
+ InBuffer<BufferAttr_HipcPointer> where);
+
+ Result GetListV1(Out<s32> out_count,
+ OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data,
+ InBuffer<BufferAttr_HipcPointer> where_phrase,
+ InBuffer<BufferAttr_HipcPointer> order_by_phrase,
+ s32 offset);
+
+ Result GetList(Out<s32> out_count,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data,
- InBuffer<BufferAttr_HipcPointer> buffer_data_1,
- InBuffer<BufferAttr_HipcPointer> buffer_data_2);
+ InBuffer<BufferAttr_HipcPointer> where_phrase,
+ InBuffer<BufferAttr_HipcPointer> order_by_phrase,
+ s32 offset);
};
} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_service.cpp b/src/core/hle/service/bcat/news/news_service.cpp
index 09f7e67aff..aed1536bfe 100644
--- a/src/core/hle/service/bcat/news/news_service.cpp
+++ b/src/core/hle/service/bcat/news/news_service.cpp
@@ -5,32 +5,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "core/hle/service/bcat/news/news_service.h"
+#include "core/hle/service/bcat/news/news_storage.h"
#include "core/hle/service/cmif_serialization.h"
+#include <cstring>
+
namespace Service::News {
INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "INewsService"} {
// clang-format off
static const FunctionInfo functions[] = {
{10100, D<&INewsService::PostLocalNews>, "PostLocalNews"},
- {20100, nullptr, "SetPassphrase"},
+ {20100, D<&INewsService::SetPassphrase>, "SetPassphrase"},
{30100, D<&INewsService::GetSubscriptionStatus>, "GetSubscriptionStatus"},
- {30101, nullptr, "GetTopicList"}, //3.0.0+
- {30110, nullptr, "Unknown30110"}, //6.0.0+
+ {30101, D<&INewsService::GetTopicList>, "GetTopicList"}, //3.0.0+
+ {30110, D<&INewsService::GetTopicList>, "Unknown30110"}, //6.0.0+ (stub)
{30200, D<&INewsService::IsSystemUpdateRequired>, "IsSystemUpdateRequired"},
- {30201, nullptr, "Unknown30201"}, //8.0.0+
- {30210, nullptr, "Unknown30210"}, //10.0.0+
+ {30201, D<&INewsService::IsSystemUpdateRequired>, "Unknown30201"}, //8.0.0+ (stub)
+ {30210, D<&INewsService::IsSystemUpdateRequired>, "Unknown30210"}, //10.0.0+ (stub)
{30300, nullptr, "RequestImmediateReception"},
- {30400, nullptr, "DecodeArchiveFile"}, //3.0.0-18.1.0
- {30500, nullptr, "Unknown30500"}, //8.0.0+
- {30900, nullptr, "Unknown30900"}, //1.0.0
- {30901, nullptr, "Unknown30901"}, //1.0.0
- {30902, nullptr, "Unknown30902"}, //1.0.0
+ {30400, nullptr, "DecodeArchiveFile"}, //3.0.0-18.1.0 (stub)
+ {30500, nullptr, "Unknown30500"}, //8.0.0+ (stub)
+ {30900, nullptr, "Unknown30900"}, //1.0.0 (stub)
+ {30901, nullptr, "Unknown30901"}, //1.0.0 (stub)
+ {30902, nullptr, "Unknown30902"}, //1.0.0 (stub)
{40100, nullptr, "SetSubscriptionStatus"},
{40101, D<&INewsService::RequestAutoSubscription>, "RequestAutoSubscription"}, //3.0.0+
- {40200, nullptr, "ClearStorage"},
- {40201, nullptr, "ClearSubscriptionStatusAll"},
- {90100, nullptr, "GetNewsDatabaseDump"},
+ {40200, D<&INewsService::ClearStorage>, "ClearStorage"},
+ {40201, D<&INewsService::ClearSubscriptionStatusAll>, "ClearSubscriptionStatusAll"},
+ {90100, D<&INewsService::GetNewsDatabaseDump>, "GetNewsDatabaseDump"},
};
// clang-format on
@@ -40,8 +43,7 @@ INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "I
INewsService::~INewsService() = default;
Result INewsService::PostLocalNews(InBuffer<BufferAttr_HipcAutoSelect> buffer_data) {
- LOG_WARNING(Service_BCAT, "(STUBBED) called, buffer_size={}", buffer_data.size());
-
+ LOG_WARNING(Service_BCAT, "(STUBBED) PostLocalNews size={}", buffer_data.size());
R_SUCCEED();
}
@@ -63,4 +65,44 @@ Result INewsService::RequestAutoSubscription(u64 value) {
R_SUCCEED();
}
+Result INewsService::SetPassphrase(InBuffer<BufferAttr_HipcPointer> buffer_data) {
+ LOG_WARNING(Service_BCAT, "(STUBBED) SetPassphrase called size={}", buffer_data.size());
+ R_SUCCEED();
+}
+
+Result INewsService::GetTopicList(Out<s32> out_count, OutBuffer<BufferAttr_HipcMapAlias> out_topics, s32 filter) {
+ constexpr size_t TopicIdSize = 32;
+ constexpr auto EdenTopicId = "eden";
+
+ const size_t max_topics = out_topics.size() / TopicIdSize;
+
+ if (max_topics == 0) {
+ *out_count = 0;
+ R_SUCCEED();
+ }
+
+ std::memset(out_topics.data(), 0, out_topics.size());
+
+ std::memcpy(out_topics.data(), EdenTopicId, std::strlen(EdenTopicId));
+ *out_count = 1;
+
+ R_SUCCEED();
+}
+
+Result INewsService::ClearStorage() {
+ LOG_WARNING(Service_BCAT, "(STUBBED) called");
+ NewsStorage::Instance().Clear();
+ R_SUCCEED();
+}
+
+Result INewsService::ClearSubscriptionStatusAll() {
+ LOG_WARNING(Service_BCAT, "(STUBBED) called");
+ R_SUCCEED();
+}
+
+Result INewsService::GetNewsDatabaseDump() {
+ LOG_WARNING(Service_BCAT, "(STUBBED) called");
+ R_SUCCEED();
+}
+
} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_service.h b/src/core/hle/service/bcat/news/news_service.h
index 245f08afc3..1a320df02f 100644
--- a/src/core/hle/service/bcat/news/news_service.h
+++ b/src/core/hle/service/bcat/news/news_service.h
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,11 +23,21 @@ public:
private:
Result PostLocalNews(InBuffer<BufferAttr_HipcAutoSelect> buffer_data);
+ Result SetPassphrase(InBuffer<BufferAttr_HipcPointer> buffer_data);
+
Result GetSubscriptionStatus(Out<u32> out_status, InBuffer<BufferAttr_HipcPointer> buffer_data);
+ Result GetTopicList(Out<s32> out_count, OutBuffer<BufferAttr_HipcMapAlias> out_topics, s32 filter);
+
Result IsSystemUpdateRequired(Out<bool> out_is_system_update_required);
Result RequestAutoSubscription(u64 value);
+
+ Result ClearStorage();
+
+ Result ClearSubscriptionStatusAll();
+
+ Result GetNewsDatabaseDump();
};
} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_storage.cpp b/src/core/hle/service/bcat/news/news_storage.cpp
new file mode 100644
index 0000000000..3590f6a91b
--- /dev/null
+++ b/src/core/hle/service/bcat/news/news_storage.cpp
@@ -0,0 +1,169 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/hle/service/bcat/news/news_storage.h"
+
+#include "common/fs/path_util.h"
+
+#include <algorithm>
+#include <chrono>
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <set>
+
+namespace Service::News {
+namespace {
+
+std::filesystem::path GetReadCachePath() {
+ return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "news_read";
+}
+
+std::set<std::string> LoadReadIds() {
+ std::set<std::string> ids;
+ std::ifstream f(GetReadCachePath());
+ std::string line;
+ while (std::getline(f, line)) {
+ if (!line.empty()) ids.insert(line);
+ }
+ return ids;
+}
+
+void SaveReadIds(const std::set<std::string>& ids) {
+ const auto path = GetReadCachePath();
+ std::error_code ec;
+ std::filesystem::create_directories(path.parent_path(), ec);
+ std::ofstream f(path);
+ for (const auto& id : ids) {
+ f << id << '\n';
+ }
+}
+
+} // namespace
+
+NewsStorage& NewsStorage::Instance() {
+ static NewsStorage s;
+ return s;
+}
+
+void NewsStorage::Clear() {
+ std::scoped_lock lk{mtx};
+ items.clear();
+}
+
+void NewsStorage::CopyZ(std::span<char> dst, std::string_view src) {
+ std::memset(dst.data(), 0, dst.size());
+ std::memcpy(dst.data(), src.data(), std::min(dst.size() - 1, src.size()));
+}
+
+std::string NewsStorage::MakeKey(std::string_view news_id, std::string_view user_id) {
+ return std::string(news_id) + "|" + std::string(user_id);
+}
+
+s64 NewsStorage::Now() {
+ using namespace std::chrono;
+ return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
+}
+
+StoredNews& NewsStorage::Upsert(std::string_view news_id, std::string_view user_id,
+ std::string_view topic_id, s64 time, std::vector<u8> payload) {
+ std::scoped_lock lk{mtx};
+
+ const auto key = MakeKey(news_id, user_id);
+ auto it = items.find(key);
+ const bool exists = it != items.end();
+
+ // implemented a little "read" file. All in cache.
+ const auto read_ids = LoadReadIds();
+ const bool was_read = read_ids.contains(std::string(news_id));
+
+ NewsRecord rec{};
+ CopyZ(rec.news_id, news_id);
+ CopyZ(rec.user_id, user_id);
+ // there is nx_notice and nx_promotion for tags pre-existing
+ CopyZ(rec.topic_id, topic_id.empty() ? "nx_notice" : topic_id);
+
+ rec.received_time = exists ? it->second.record.received_time : (time ? time : Now());
+ rec.read = was_read ? 1 : (exists ? it->second.record.read : 0);
+ rec.newly = was_read ? 0 : 1;
+ rec.displayed = exists ? it->second.record.displayed : 0;
+ rec.extra1 = exists ? it->second.record.extra1 : 0;
+ rec.extra2 = exists ? it->second.record.extra2 : 0;
+
+ auto& entry = items[key];
+ entry.record = rec;
+ entry.payload = std::move(payload);
+ return entry;
+}
+
+StoredNews& NewsStorage::UpsertRaw(const GithubNewsMeta& meta, std::vector<u8> payload) {
+ return Upsert(meta.news_id, "", meta.topic_id, static_cast<s64>(meta.published_at), std::move(payload));
+}
+
+std::vector<NewsRecord> NewsStorage::ListAll() const {
+ std::scoped_lock lk{mtx};
+
+ std::vector<NewsRecord> out;
+ out.reserve(items.size());
+ for (const auto& [_, v] : items) {
+ out.push_back(v.record);
+ }
+
+ std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) {
+ return a.received_time > b.received_time;
+ });
+ return out;
+}
+
+std::optional<StoredNews> NewsStorage::FindByNewsId(std::string_view news_id,
+ std::string_view user_id) const {
+ std::scoped_lock lk{mtx};
+
+ if (auto it = items.find(MakeKey(news_id, user_id)); it != items.end()) {
+ return it->second;
+ }
+ if (!user_id.empty()) {
+ if (auto it = items.find(MakeKey(news_id, "")); it != items.end()) {
+ return it->second;
+ }
+ }
+ return std::nullopt;
+}
+
+bool NewsStorage::UpdateRecord(std::string_view news_id, std::string_view user_id,
+ const std::function<void(NewsRecord&)>& updater) {
+ std::scoped_lock lk{mtx};
+
+ if (auto it = items.find(MakeKey(news_id, user_id)); it != items.end()) {
+ updater(it->second.record);
+ return true;
+ }
+ return false;
+}
+
+void NewsStorage::MarkAsRead(std::string_view news_id) {
+ std::scoped_lock lk{mtx};
+ for (auto& [_, entry] : items) {
+ if (std::string_view(entry.record.news_id.data()) == news_id) {
+ entry.record.read = 1;
+ entry.record.newly = 0;
+ break;
+ }
+ }
+ auto ids = LoadReadIds();
+ ids.insert(std::string(news_id));
+ SaveReadIds(ids);
+}
+
+size_t NewsStorage::GetAndIncrementOpenCounter() {
+ std::scoped_lock lk{mtx};
+ return open_counter++;
+}
+
+void NewsStorage::ResetOpenCounter() {
+ std::scoped_lock lk{mtx};
+ open_counter = 0;
+}
+
+
+} // namespace Service::News
diff --git a/src/core/hle/service/bcat/news/news_storage.h b/src/core/hle/service/bcat/news/news_storage.h
new file mode 100644
index 0000000000..ad84e4080c
--- /dev/null
+++ b/src/core/hle/service/bcat/news/news_storage.h
@@ -0,0 +1,100 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+#include <functional>
+#include <mutex>
+#include <optional>
+#include <span>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "common/common_types.h"
+
+namespace Service::News {
+
+struct GithubNewsMeta {
+ std::string news_id;
+ std::string topic_id;
+ u64 published_at{};
+ u64 pickup_limit{};
+ u64 essential_pickup_limit{};
+ u64 expire_at{};
+ u32 priority{};
+ u32 deletion_priority{100};
+ u32 decoration_type{1};
+ u32 opted_in{1};
+ u32 essential_pickup_limit_flag{1};
+ u32 category{};
+ u32 language_mask{1};
+};
+
+struct NewsRecordV1 {
+ std::array<char, 24> news_id{};
+ std::array<char, 24> user_id{};
+ s64 received_time{};
+ s32 read{};
+ s32 newly{};
+ s32 displayed{};
+ s32 extra1{};
+};
+static_assert(sizeof(NewsRecordV1) == 72);
+
+struct NewsRecord {
+ std::array<char, 24> news_id{};
+ std::array<char, 24> user_id{};
+ std::array<char, 32> topic_id{};
+ s64 received_time{};
+ std::array<u8, 12> _pad1{};
+ s32 read{};
+ s32 newly{};
+ s32 displayed{};
+ std::array<u8, 8> _pad2{};
+ s32 extra1{};
+ s32 extra2{};
+};
+static_assert(sizeof(NewsRecord) == 128);
+
+struct StoredNews {
+ NewsRecord record{};
+ std::vector<u8> payload;
+};
+
+class NewsStorage {
+public:
+ static NewsStorage& Instance();
+
+ void Clear();
+ StoredNews& Upsert(std::string_view news_id, std::string_view user_id,
+ std::string_view topic_id, s64 time, std::vector<u8> payload);
+ StoredNews& UpsertRaw(const GithubNewsMeta& meta, std::vector<u8> payload);
+
+ std::vector<NewsRecord> ListAll() const;
+ std::optional<StoredNews> FindByNewsId(std::string_view news_id,
+ std::string_view user_id = {}) const;
+ bool UpdateRecord(std::string_view news_id, std::string_view user_id,
+ const std::function<void(NewsRecord&)>& updater);
+ void MarkAsRead(std::string_view news_id);
+
+ size_t GetAndIncrementOpenCounter();
+ void ResetOpenCounter();
+
+private:
+ NewsStorage() = default;
+
+ static std::string MakeKey(std::string_view news_id, std::string_view user_id);
+ static void CopyZ(std::span<char> dst, std::string_view src);
+ static s64 Now();
+
+ mutable std::mutex mtx;
+ std::unordered_map<std::string, StoredNews> items;
+ size_t open_counter{};
+};
+
+} // namespace Service::News
diff --git a/src/core/hle/service/ns/application_manager_interface.cpp b/src/core/hle/service/ns/application_manager_interface.cpp
index 77481a0d98..f989bdc0a8 100644
--- a/src/core/hle/service/ns/application_manager_interface.cpp
+++ b/src/core/hle/service/ns/application_manager_interface.cpp
@@ -16,6 +16,10 @@
#include "core/hle/service/ns/read_only_application_control_data_interface.h"
#include "core/file_sys/patch_manager.h"
#include "frontend_common/firmware_manager.h"
+#include "core/launch_timestamp_cache.h"
+
+#include <algorithm>
+#include <vector>
namespace Service::NS {
@@ -560,33 +564,42 @@ Result IApplicationManagerInterface::ListApplicationRecord(
s32 offset) {
const auto limit = out_records.size();
- LOG_WARNING(Service_NS, "(STUBBED) called");
+ LOG_DEBUG(Service_NS, "called");
const auto& cache = system.GetContentProviderUnion();
const auto installed_games = cache.ListEntriesFilterOrigin(
std::nullopt, FileSys::TitleType::Application, FileSys::ContentRecordType::Program);
- size_t i = 0;
- u8 ii = 24;
+ std::vector<ApplicationRecord> records;
+ records.reserve(installed_games.size());
for (const auto& [slot, game] : installed_games) {
- if (i >= limit) {
- break;
- }
- if (game.title_id == 0 || game.title_id < 0x0100000000001FFFull) {
- continue;
- }
- if (offset > 0) {
- offset--;
- continue;
- }
+ if (game.title_id == 0 || game.title_id < 0x0100000000001FFFull) {
+ continue;
+ }
+ if ((game.title_id & 0xFFF) != 0) {
+ continue; // skip sub-programs (e.g., 001)
+ }
+
+ ApplicationRecord record{};
+ record.application_id = game.title_id;
+ record.last_event = ApplicationEvent::Installed;
+ record.attributes = 0;
+ record.last_updated = Core::LaunchTimestampCache::GetLaunchTimestamp(game.title_id);
+
+ records.push_back(record);
+ }
+
+ std::sort(records.begin(), records.end(), [](const ApplicationRecord& lhs, const ApplicationRecord& rhs) {
+ if (lhs.last_updated == rhs.last_updated) {
+ return lhs.application_id < rhs.application_id;
+ }
+ return lhs.last_updated > rhs.last_updated;
+ });
- ApplicationRecord record{};
- record.application_id = game.title_id;
- record.type = ApplicationRecordType::Installed;
- record.unknown = 0; // 2 = needs update
- record.unknown2 = ii++;
-
- out_records[i++] = record;
+ size_t i = 0;
+ const size_t start = static_cast<size_t>(std::max(0, offset));
+ for (size_t idx = start; idx < records.size() && i < limit; ++idx) {
+ out_records[i++] = records[idx];
}
*out_count = static_cast<s32>(i);
diff --git a/src/core/hle/service/ns/ns_types.h b/src/core/hle/service/ns/ns_types.h
index 247f7063af..c581e8d6c3 100644
--- a/src/core/hle/service/ns/ns_types.h
+++ b/src/core/hle/service/ns/ns_types.h
@@ -12,7 +12,7 @@
namespace Service::NS {
-enum class ApplicationRecordType : u8 {
+enum class ApplicationEvent : u8 {
Installing = 2,
Installed = 3,
GameCardNotInserted = 5,
@@ -34,11 +34,10 @@ enum class BackgroundNetworkUpdateState : u8 {
struct ApplicationRecord {
u64 application_id;
- ApplicationRecordType type;
- u8 unknown;
+ ApplicationEvent last_event;
+ u8 attributes;
INSERT_PADDING_BYTES_NOINIT(0x6);
- u8 unknown2;
- INSERT_PADDING_BYTES_NOINIT(0x7);
+ s64 last_updated;
};
static_assert(sizeof(ApplicationRecord) == 0x18, "ApplicationRecord has incorrect size.");
diff --git a/src/core/launch_timestamp_cache.cpp b/src/core/launch_timestamp_cache.cpp
new file mode 100644
index 0000000000..5a154a5c2f
--- /dev/null
+++ b/src/core/launch_timestamp_cache.cpp
@@ -0,0 +1,135 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "core/launch_timestamp_cache.h"
+
+#include <filesystem>
+#include <fstream>
+#include <mutex>
+#include <sstream>
+#include <string>
+#include <unordered_map>
+
+#include <fmt/format.h>
+#include <nlohmann/json.hpp>
+
+#include "common/fs/fs.h"
+#include "common/fs/file.h"
+#include "common/fs/path_util.h"
+#include "common/logging/log.h"
+
+namespace Core::LaunchTimestampCache {
+namespace {
+
+using CacheMap = std::unordered_map<u64, s64>;
+
+std::mutex mutex;
+CacheMap cache;
+bool loaded = false;
+
+std::filesystem::path GetCachePath() {
+ return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "launched.json";
+}
+
+std::optional<std::string> ReadFileToString(const std::filesystem::path& path) {
+ const std::ifstream file{path, std::ios::in | std::ios::binary};
+ if (!file) {
+ return std::nullopt;
+ }
+ std::ostringstream ss;
+ ss << file.rdbuf();
+ return ss.str();
+}
+
+bool WriteStringToFile(const std::filesystem::path& path, const std::string& data) {
+ if (!Common::FS::CreateParentDirs(path)) {
+ return false;
+ }
+ std::ofstream file{path, std::ios::out | std::ios::binary | std::ios::trunc};
+ if (!file) {
+ return false;
+ }
+ file.write(data.data(), static_cast<std::streamsize>(data.size()));
+ return static_cast<bool>(file);
+}
+
+void Load() {
+ if (loaded) {
+ return;
+ }
+
+ loaded = true;
+
+ const auto path = GetCachePath();
+ if (!std::filesystem::exists(path)) {
+ return;
+ }
+
+ const auto data = ReadFileToString(path);
+ if (!data) {
+ LOG_WARNING(Core, "Failed to read launch timestamp cache: {}",
+ Common::FS::PathToUTF8String(path));
+ return;
+ }
+
+ try {
+ const auto json = nlohmann::json::parse(data->data(), data->data() + data->size());
+ if (!json.is_object()) {
+ return;
+ }
+ for (auto it = json.begin(); it != json.end(); ++it) {
+ const auto key_str = it.key();
+ const auto value = it.value();
+ u64 key{};
+ try {
+ key = std::stoull(key_str, nullptr, 16);
+ } catch (...) {
+ continue;
+ }
+ if (value.is_number_integer()) {
+ cache[key] = value.get<s64>();
+ }
+ }
+ } catch (const std::exception& e) {
+ LOG_WARNING(Core, "Failed to parse launch timestamp cache");
+ }
+}
+
+void Save() {
+ nlohmann::json json = nlohmann::json::object();
+ for (const auto& [key, value] : cache) {
+ json[fmt::format("{:016X}", key)] = value;
+ }
+
+ const auto path = GetCachePath();
+ if (!WriteStringToFile(path, json.dump(4))) {
+ LOG_WARNING(Core, "Failed to write launch timestamp cache: {}",
+ Common::FS::PathToUTF8String(path));
+ }
+}
+
+s64 NowSeconds() {
+ return std::time(nullptr);
+}
+
+} // namespace
+
+void SaveLaunchTimestamp(u64 title_id) {
+ std::scoped_lock lk{mutex};
+ Load();
+ cache[title_id] = NowSeconds();
+ Save();
+}
+
+s64 GetLaunchTimestamp(u64 title_id) {
+ std::scoped_lock lk{mutex};
+ Load();
+ const auto it = cache.find(title_id);
+ if (it != cache.end()) {
+ return it->second;
+ }
+ // we need a timestamp, i decided on 01/01/2026 00:00
+ return 1767225600;
+}
+
+} // namespace Core::LaunchTimestampCache
diff --git a/src/core/launch_timestamp_cache.h b/src/core/launch_timestamp_cache.h
new file mode 100644
index 0000000000..b3722033cb
--- /dev/null
+++ b/src/core/launch_timestamp_cache.h
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include "common/common_types.h"
+
+namespace Core::LaunchTimestampCache {
+
+void SaveLaunchTimestamp(u64 title_id);
+s64 GetLaunchTimestamp(u64 title_id);
+
+} // namespace Core::LaunchTimestampCache