diff --git a/delta/formats/pkg_object.cpp b/delta/formats/pkg_object.cpp index 7b9e1c17..7738bd58 100644 --- a/delta/formats/pkg_object.cpp +++ b/delta/formats/pkg_object.cpp @@ -674,7 +674,11 @@ struct PkgImpl { if (be32(e) != wantId) continue; uint32_t off = be32(e + 16), sz = be32(e + 20); - if (sz == 0 || static_cast(off) + sz > pkg.GetSize()) + const uint32_t maxSize = + wantId == 0x1000 ? 1u << 20 + : (wantId == 0x1200 ? 16u << 20 : UINT32_MAX); + if (sz == 0 || sz > maxSize || + static_cast(off) + sz > pkg.GetSize()) return false; out.resize(sz); R(off, sz, out.data()); diff --git a/delta/gfx/CMakeLists.txt b/delta/gfx/CMakeLists.txt index e205c1b3..71478d2a 100644 --- a/delta/gfx/CMakeLists.txt +++ b/delta/gfx/CMakeLists.txt @@ -26,4 +26,19 @@ else() ${_imgui}/imgui_tables.cpp ${_imgui}/imgui_widgets.cpp) target_include_directories(delta_gfx PRIVATE ${_imgui}) + + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(_logo ${CMAKE_SOURCE_DIR}/delta/main/_res/logo.png) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${_logo}) + file(READ ${_logo} _logo_hex HEX) + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _logo_bytes + "${_logo_hex}") + file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/prosperity_logo.h + CONTENT + "static constexpr unsigned char kProsperityLogoPng[] = {${_logo_bytes}};\n" + @ONLY) + target_include_directories(delta_gfx PRIVATE + ${CMAKE_CURRENT_BINARY_DIR} + ${CMAKE_SOURCE_DIR}/vendor/equilibrium/external/tracy/profiler/src) + endif() endif() diff --git a/delta/gfx/gfx.h b/delta/gfx/gfx.h index 39eedcaf..b80c6786 100644 --- a/delta/gfx/gfx.h +++ b/delta/gfx/gfx.h @@ -8,6 +8,7 @@ * in the root of the source tree. */ +#include #include // SDL3 window backed by a Vulkan swapchain. present() uploads a CPU framebuffer @@ -24,6 +25,10 @@ enum class PixelFormat { // already exists, otherwise it overrides the title the creator passes. void setTitle(const char *title); +// Set PNG artwork for the game window. The desktop backend applies a small +// emulator badge before using it as the taskbar icon. +void setIcon(const uint8_t *png, size_t size); + // Create the window, Vulkan device and swapchain. Returns false on failure. // Idempotent: returns true immediately if a window already exists. bool init(const char *title, uint32_t width, uint32_t height); diff --git a/delta/gfx/gfx_vk.cpp b/delta/gfx/gfx_vk.cpp index 44c27c9a..414007e0 100644 --- a/delta/gfx/gfx_vk.cpp +++ b/delta/gfx/gfx_vk.cpp @@ -17,6 +17,7 @@ // (gfx_headless.cpp) and the GPU renderer dumps frames instead of presenting. #ifndef __ANDROID__ +#include #include #include #include @@ -26,6 +27,14 @@ #include #include +#if defined(__linux__) +#define STBI_ONLY_PNG +#define STB_IMAGE_IMPLEMENTATION +#include + +#include "prosperity_logo.h" +#endif + #include #include #include @@ -52,6 +61,7 @@ namespace { // videoout HLE race to bring the window up and each passes its own generic // title, so whoever wins uses this instead when it is set. std::string g_title; +std::vector g_iconPng; constexpr uint32_t kFrameSlotCount = 2; @@ -106,6 +116,75 @@ struct State { State g; std::atomic_bool g_canPresent{true}; constexpr uint64_t kPresentWaitSliceNs = 50'000'000; +constexpr size_t kMaxIconSize = 16u << 20; +constexpr int kMaxIconDimension = 4096; + +#if defined(__linux__) +void drawBadge(uint8_t *pixels, int width, int height, const uint8_t *logo, + int logoWidth, int logoHeight) { + const int size = std::max(1, std::min(width, height) * 3 / 8); + const int left = width - size; + for (int y = 0; y < size; ++y) { + for (int x = 0; x < size; ++x) { + const int px = left + x; + uint8_t *rgba = pixels + (static_cast(y) * width + px) * 4; + const uint8_t *badge = + logo + (static_cast(y * logoHeight / size) * logoWidth + + x * logoWidth / size) * + 4; + const uint32_t alpha = badge[3]; + const uint32_t dstAlpha = rgba[3]; + const uint32_t outAlpha = alpha * 255 + dstAlpha * (255 - alpha); + if (outAlpha) { + for (int channel = 0; channel < 3; ++channel) + rgba[channel] = static_cast( + (badge[channel] * alpha * 255 + + rgba[channel] * dstAlpha * (255 - alpha) + outAlpha / 2) / + outAlpha); + } + rgba[3] = static_cast((outAlpha + 127) / 255); + } + } +} + +void applyWindowIcon() { + if (!g.window || g_iconPng.empty() || g_iconPng.size() > kMaxIconSize) + return; + int width = 0; + int height = 0; + int channels = 0; + if (!stbi_info_from_memory(g_iconPng.data(), + static_cast(g_iconPng.size()), &width, + &height, &channels) || + width > kMaxIconDimension || height > kMaxIconDimension) + return; + stbi_uc *pixels = stbi_load_from_memory( + g_iconPng.data(), static_cast(g_iconPng.size()), &width, &height, + &channels, STBI_rgb_alpha); + if (!pixels) { + stbi_image_free(pixels); + return; + } + int logoWidth = 0; + int logoHeight = 0; + stbi_uc *logo = + stbi_load_from_memory(kProsperityLogoPng, sizeof(kProsperityLogoPng), + &logoWidth, &logoHeight, nullptr, STBI_rgb_alpha); + if (!logo) { + stbi_image_free(pixels); + return; + } + drawBadge(pixels, width, height, logo, logoWidth, logoHeight); + SDL_Surface *surface = SDL_CreateSurfaceFrom( + width, height, SDL_PIXELFORMAT_RGBA32, pixels, width * STBI_rgb_alpha); + if (surface) { + SDL_SetWindowIcon(g.window, surface); + SDL_DestroySurface(surface); + } + stbi_image_free(logo); + stbi_image_free(pixels); +} +#endif void stopPresenting(const char *operation, VkResult result) { std::fprintf(stderr, "[gfx] %s failed: VkResult=%d\n", operation, result); @@ -425,6 +504,9 @@ bool init(const char *title, uint32_t width, uint32_t height) { std::fprintf(stderr, "[gfx] SDL_CreateWindow failed: %s\n", SDL_GetError()); return false; } +#if defined(__linux__) + applyWindowIcon(); +#endif // Instance: SDL-required extensions + optional validation. uint32_t nExt = 0; @@ -761,6 +843,18 @@ void setTitle(const char *title) { SDL_SetWindowTitle(g.window, g_title.c_str()); } +void setIcon(const uint8_t *png, size_t size) { +#if defined(__linux__) + if (size > kMaxIconSize) + return; + g_iconPng.assign(png, png + size); + applyWindowIcon(); +#else + (void)png; + (void)size; +#endif +} + bool available() { return g.window != nullptr && g.swapchain != VK_NULL_HANDLE; } diff --git a/delta/main/_res/logo.png b/delta/main/_res/logo.png index fa317fa8..ca6cfa7a 100644 Binary files a/delta/main/_res/logo.png and b/delta/main/_res/logo.png differ diff --git a/delta/main/dcore.cpp b/delta/main/dcore.cpp index 771213b2..a71c0879 100644 --- a/delta/main/dcore.cpp +++ b/delta/main/dcore.cpp @@ -35,6 +35,9 @@ bool deltaCore::init() { } namespace { +constexpr uint64_t kMaxSfoSize = 1u << 20; +constexpr uint64_t kMaxIconSize = 16u << 20; + // Minimal param.sfo reader: return the string value of `key` (e.g. "TITLE_ID"), // or "" if absent. The SFO is a small flat table; all offsets are bounds-checked. std::string sfoGet(const uint8_t *d, size_t n, const char *key) { @@ -90,6 +93,28 @@ std::string jsonGetString(const std::string &js, const char *key) { return js.substr(open + 1, close - open - 1); } +bool readHostFile(const std::string &path, uint64_t maxSize, + std::vector &out) { + utl::File file(base::String(path.c_str()), utl::fileMode::read); + if (!file.IsOpen()) + return false; + const uint64_t size = file.GetSize(); + if (size == 0 || size > maxSize) + return false; + out.resize(static_cast(size)); + if (file.Read(out.data(), out.size()) != size) { + out.clear(); + return false; + } + return true; +} + +std::string parentPath(const base::String &path) { + const std::string value(path.c_str()); + const size_t slash = value.find_last_of("/\\"); + return slash == std::string::npos ? std::string(".") : value.substr(0, slash); +} + // param.json stores sdkVersion as "0xMMmmpppp00000000"; libkernel wants the top // half (0x03000000 for a 3.00 title). Empty/unparsable -> 0. uint32_t parseSdkVersion(const std::string &s) { @@ -154,6 +179,19 @@ class PkgProvider : public krnl::vfs::VirtualProvider { return {}; } + std::string title() { + std::vector sfo; + if (fs_.readPkgEntry(0x1000, sfo) > 0) + return sfoGet(sfo.data(), sfo.size(), "TITLE"); + return {}; + } + + std::vector icon() { + std::vector png; + fs_.readPkgEntry(0x1200, png); + return png; + } + // SOTTR workaround: cache every .manifest.bin's bytes keyed by its base name // (e.g. "PRIORITY7_ENGLISH"), so the count-setter can fill the header buffer // with correct data (the engine's async manifest reader races on our threads). @@ -305,6 +343,18 @@ class Ufs2Provider : public krnl::vfs::VirtualProvider { // instead of the PS4 param.sfo; pull the "titleId" string out of it. std::string titleId() { return paramJsonField("titleId"); } + std::vector icon() { + const auto *node = fs_.find("/sce_sys/icon0.png"); + if (!node || node->size > kMaxIconSize) + return {}; + std::vector png(node->size); + const int64_t read = fs_.read(*node, png.data(), 0, node->size); + if (read <= 0) + return {}; + png.resize(static_cast(read)); + return png; + } + // param.json spells the SDK version as a 64-bit hex string ("0x0300...") // whose top half is the 0xMMmmpppp form libkernel compares against. uint32_t sdkVersion() { return parseSdkVersion(paramJsonField("sdkVersion")); } @@ -361,13 +411,24 @@ void deltaCore::boot(const base::String &xdir) { const bool isPkg = endsWithIgnoreCase(xdir, ".pkg"); const bool isFfpkg = endsWithIgnoreCase(xdir, ".ffpkg"); - // A raw app dump: the extracted /app0 tree itself, identified by its PS5 - // sce_sys/param.json. Host-mounted rather than read through an image reader. - std::string appJson = std::string(path.c_str()) + "/sce_sys/param.json"; - const bool isAppDir = !isPkg && !isFfpkg && utl::File(base::String(appJson.c_str()), - utl::fileMode::read).Exists(); + // A raw app dump: the extracted /app0 tree itself, identified by its console + // metadata. Host-mounted rather than read through an image reader. + const std::string appRoot(path.c_str()); + const std::string appSfo = appRoot + "/sce_sys/param.sfo"; + const std::string appJson = appRoot + "/sce_sys/param.json"; + const bool isPs4AppDir = + !isPkg && !isFfpkg && + utl::File(base::String(appSfo.c_str()), utl::fileMode::read).Exists(); + const bool isPs5AppDir = + !isPkg && !isFfpkg && !isPs4AppDir && + utl::File(base::String(appJson.c_str()), utl::fileMode::read).Exists(); + const bool isAppDir = isPs4AppDir || isPs5AppDir; base::String mainModule = path; uint32_t sdkVersion = 0; + std::string gameTitle; +#if defined(__linux__) && !defined(__ANDROID__) + std::vector gameIcon; +#endif if (isPkg) { auto provider = std::make_shared(path); @@ -380,6 +441,10 @@ void deltaCore::boot(const base::String &xdir) { // Publish the title id so savedata can give this game its own host save // root (else saves for different titles collide under one directory). krnl::vfs::setTitleId(provider->titleId()); + gameTitle = provider->title(); +#if defined(__linux__) && !defined(__ANDROID__) + gameIcon = provider->icon(); +#endif mainModule = base::String("/app0/eboot.bin"); } else if (isFfpkg) { // PS5 game backup (UFS2). Mount it at /app0 and prefer the decrypted/ tree @@ -393,6 +458,9 @@ void deltaCore::boot(const base::String &xdir) { bool decrypted = provider->hasDecrypted(); krnl::vfs::mountVirtual("/app0", provider); krnl::vfs::setTitleId(provider->titleId()); +#if defined(__linux__) && !defined(__ANDROID__) + gameIcon = provider->icon(); +#endif sdkVersion = provider->sdkVersion(); mainModule = base::String(decrypted ? "/app0/decrypted/eboot.bin" : "/app0/eboot.bin"); @@ -400,15 +468,43 @@ void deltaCore::boot(const base::String &xdir) { krnl::vfs::titleId().c_str(), mainModule.c_str()); } else if (isAppDir) { krnl::vfs::mount("/app0", path.c_str()); - if (utl::File f(base::String(appJson.c_str()), utl::fileMode::read); f.IsOpen()) { - std::string js(static_cast(f.GetSize()), '\0'); - f.Read(js.data(), js.size()); + if (isPs4AppDir) { + std::vector sfo; + if (readHostFile(appSfo, kMaxSfoSize, sfo)) { + krnl::vfs::setTitleId(sfoGet(sfo.data(), sfo.size(), "TITLE_ID")); + gameTitle = sfoGet(sfo.data(), sfo.size(), "TITLE"); + } +#if defined(__linux__) && !defined(__ANDROID__) + if (!readHostFile(appRoot + "/sce_sys/icon0.png", kMaxIconSize, gameIcon)) + readHostFile(appRoot + "/icon0.png", kMaxIconSize, gameIcon); +#endif + } else { + std::vector json; + readHostFile(appJson, kMaxSfoSize, json); + const std::string js(json.begin(), json.end()); krnl::vfs::setTitleId(jsonGetString(js, "titleId")); sdkVersion = parseSdkVersion(jsonGetString(js, "sdkVersion")); +#if defined(__linux__) && !defined(__ANDROID__) + if (!readHostFile(appRoot + "/sce_sys/icon0.png", kMaxIconSize, gameIcon)) + readHostFile(appRoot + "/icon0.png", kMaxIconSize, gameIcon); +#endif } mainModule = base::String("/app0/eboot.bin"); LOG_INFO("mounted app dir at /app0 ({}), boot module {}", krnl::vfs::titleId().c_str(), mainModule.c_str()); + } else { + const std::string root = parentPath(path); + std::vector sfo; + if (!readHostFile(root + "/sce_sys/param.sfo", kMaxSfoSize, sfo)) + readHostFile(root + "/param.sfo", kMaxSfoSize, sfo); + if (!sfo.empty()) { + krnl::vfs::setTitleId(sfoGet(sfo.data(), sfo.size(), "TITLE_ID")); + gameTitle = sfoGet(sfo.data(), sfo.size(), "TITLE"); + } +#if defined(__linux__) && !defined(__ANDROID__) + if (!readHostFile(root + "/sce_sys/icon0.png", kMaxIconSize, gameIcon)) + readHostFile(root + "/icon0.png", kMaxIconSize, gameIcon); +#endif } // /download0 is the title's writable data volume (patches, add-on content, @@ -426,14 +522,22 @@ void deltaCore::boot(const base::String &xdir) { // These all boot from an /app0 mount rather than a bare host path. const bool mounted = isPkg || isFfpkg || isAppDir; - const bool isPs5 = isFfpkg || isAppDir; - // Name the window after the booted title, since the renderer and the videoout + const bool isPs5 = isFfpkg || isPs5AppDir; + // Name the window after the booted game, since the renderer and the videoout // HLE both bring it up with a generic title depending on who gets there first. { const std::string &tid = krnl::vfs::titleId(); - gfx::setTitle(("prosperity - " + (tid.empty() ? std::string("unknown") : tid) + - (isPs5 ? " (PS5)" : " (PS4)")).c_str()); + std::string title = + gameTitle.empty() ? (tid.empty() ? std::string("unknown") : tid) + : gameTitle; + title += " - prosperity"; + title += isPs5 ? " (PS5)" : " (PS4)"; + gfx::setTitle(title.c_str()); } +#if defined(__linux__) && !defined(__ANDROID__) + if (!gameIcon.empty()) + gfx::setIcon(gameIcon.data(), gameIcon.size()); +#endif std::thread ctx([mainModule = std::move(mainModule), mounted, isPs5, sdkVersion]() { auto p = base::MakeUnique(); if (isPs5)