From 58f90eea9dc65af2342f2130a4b3e9b89bb7e6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Thu, 1 Oct 2026 19:14:52 +0200 Subject: [PATCH] feat(libScePngEnc): implement PNG encoding scePngEncQueryMemorySize, scePngEncCreate, scePngEncEncode and scePngEncDelete encode R8G8B8A8 and B8G8R8A8 images with a pitch into RGB or RGBA PNGs using the shared encoder in core/Decoder/Png. Decoder::Png::Encode takes optional compression level and filter options so the encode parameters are honoured. Co-Authored-By: Claude --- core/Decoder/Png/include/Decoder/Png.hpp | 7 +- core/Decoder/Png/src/Png.cpp | 10 +- core/Decoder/Png/tests/Png.cpp | 13 +++ core/libs/SceTypes.hpp | 28 +++++ core/libs/prx/libScePngEnc/CMakeLists.txt | 23 ++++ core/libs/prx/libScePngEnc/Export.cpp | 131 ++++++++++++++++++++-- core/libs/tests/GuestPngEnc.cpp | 118 +++++++++++++++++++ docs/dev/TechnicalDebt.md | 1 + 8 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 core/libs/prx/libScePngEnc/CMakeLists.txt create mode 100644 core/libs/tests/GuestPngEnc.cpp diff --git a/core/Decoder/Png/include/Decoder/Png.hpp b/core/Decoder/Png/include/Decoder/Png.hpp index 7643a7a4c..051b5f230 100644 --- a/core/Decoder/Png/include/Decoder/Png.hpp +++ b/core/Decoder/Png/include/Decoder/Png.hpp @@ -35,8 +35,13 @@ std::optional
ParseHeader(std::span png); std::optional Decode(std::span png); +struct EncodeOptions { + int compressionLevel = 8; + int filter = -1; +}; + std::vector Encode(std::span pixels, std::uint32_t width, std::uint32_t height, - std::uint32_t channels); + std::uint32_t channels, EncodeOptions options = {}); } // namespace Decoder::Png diff --git a/core/Decoder/Png/src/Png.cpp b/core/Decoder/Png/src/Png.cpp index 66a5e43c1..7e8d40b8d 100644 --- a/core/Decoder/Png/src/Png.cpp +++ b/core/Decoder/Png/src/Png.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #define STB_IMAGE_WRITE_STATIC @@ -102,8 +103,11 @@ std::optional Decode(std::span png) { } std::vector Encode(std::span pixels, std::uint32_t width, std::uint32_t height, - std::uint32_t channels) { + std::uint32_t channels, EncodeOptions options) { if (channels < 1 || channels > 4) throw std::invalid_argument("Png::Encode: channels must be 1-4"); + if (options.compressionLevel < 0 || options.compressionLevel > 9 || options.filter < -1 || options.filter > 4) { + throw std::invalid_argument("Png::Encode: unsupported options"); + } if (width == 0 || height == 0 || static_cast(width) * channels > INT_MAX || height > INT_MAX) { throw std::invalid_argument("Png::Encode: unsupported image size"); } @@ -113,6 +117,10 @@ std::vector Encode(std::span pixels, std::uint std::vector output; const int stride = static_cast(width * channels); + static std::mutex optionsMutex; + std::lock_guard lock(optionsMutex); + stbi_write_png_compression_level = options.compressionLevel; + stbi_write_force_png_filter = options.filter; const int written = stbi_write_png_to_func(appendBytes, &output, static_cast(width), static_cast(height), static_cast(channels), pixels.data(), stride); if (written == 0) throw std::runtime_error("Png::Encode: encoding failed"); diff --git a/core/Decoder/Png/tests/Png.cpp b/core/Decoder/Png/tests/Png.cpp index 8fcd78e8b..9129f03dc 100644 --- a/core/Decoder/Png/tests/Png.cpp +++ b/core/Decoder/Png/tests/Png.cpp @@ -140,6 +140,19 @@ int main() { Require(ThrowsInvalidArgument([&] { Decoder::Png::Encode(pixels, 4, 4, 5); })); Require(ThrowsInvalidArgument([&] { Decoder::Png::Encode(pixels, 0, 4, 4); })); Require(ThrowsInvalidArgument([&] { Decoder::Png::Encode(pixels, 4, 5, 4); })); + Require(ThrowsInvalidArgument([&] { Decoder::Png::Encode(pixels, 4, 4, 4, {10, -1}); })); + Require(ThrowsInvalidArgument([&] { Decoder::Png::Encode(pixels, 4, 4, 4, {8, 5}); })); + + std::vector gradient(64 * 64 * 3); + for (std::size_t i = 0; i < gradient.size(); ++i) gradient[i] = static_cast(i / 3 % 64 * 4); + const auto stored = Decoder::Png::Encode(gradient, 64, 64, 3, {0, 0}); + const auto compressed = Decoder::Png::Encode(gradient, 64, 64, 3, {9, 1}); + Require(compressed.size() < stored.size()); + for (const auto& png : {stored, compressed}) { + const auto image = Decoder::Png::Decode(png); + Require(image.has_value() && image->width == 64 && image->height == 64); + for (std::size_t pixel = 0; pixel < 64 * 64; ++pixel) Require(image->pixels[pixel * 4] == gradient[pixel * 3]); + } return 0; } diff --git a/core/libs/SceTypes.hpp b/core/libs/SceTypes.hpp index 86bd95651..ecbbde121 100644 --- a/core/libs/SceTypes.hpp +++ b/core/libs/SceTypes.hpp @@ -1406,6 +1406,34 @@ struct ContentExportInitParam2 { struct ContentSearchInitParam { std::size_t memory_size; }; +struct PngEncCreateParam { + std::uint32_t this_size; + std::uint32_t attribute; + std::uint32_t max_image_width; + std::uint32_t max_filter_number; +}; + +struct PngEncEncodeParam { + const std::uint8_t* image_mem_addr; + std::uint8_t* png_mem_addr; + std::uint32_t image_mem_size; + std::uint32_t png_mem_size; + std::uint32_t image_width; + std::uint32_t image_height; + std::uint32_t image_pitch; + std::uint16_t pixel_format; + std::uint16_t color_space; + std::uint16_t bit_depth; + std::uint16_t clut_number; + std::uint16_t filter_type; + std::uint16_t compression_level; +}; + +struct PngEncOutputInfo { + std::uint32_t data_size; + std::uint32_t processed_height; +}; + struct ContentDeleteInitParam { char reserved1[4]; std::size_t heap_size; diff --git a/core/libs/prx/libScePngEnc/CMakeLists.txt b/core/libs/prx/libScePngEnc/CMakeLists.txt new file mode 100644 index 000000000..7097396a3 --- /dev/null +++ b/core/libs/prx/libScePngEnc/CMakeLists.txt @@ -0,0 +1,23 @@ +add_library(libScePngEnc SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_SOURCE_DIR}/Export.cpp) +target_include_directories(libScePngEnc PRIVATE ${LIBS_INCLUDE_DIR}) +set_target_properties(libScePngEnc PROPERTIES CXX_EXTENSIONS OFF) +configure_windows_unwind(libScePngEnc) +target_link_libraries(libScePngEnc PRIVATE decoder_png) + +if(MINGW) + foreach(runtimeLinkFlag IN ITEMS -static-libgcc -static-libstdc++ -static) + string(REPLACE "${runtimeLinkFlag}" "" CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS}") + endforeach() +endif() + +if(BUILD_TESTING) + add_test_executable(guest_png_enc_tests ${LIBS_INCLUDE_DIR}/tests/GuestPngEnc.cpp) + target_include_directories(guest_png_enc_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_png_enc_tests PRIVATE libScePngEnc decoder_png libc) + configure_windows_unwind(guest_png_enc_tests) + add_test(NAME guest_png_enc COMMAND guest_png_enc_tests) + if(WIN32) + set_tests_properties(guest_png_enc PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() +endif() diff --git a/core/libs/prx/libScePngEnc/Export.cpp b/core/libs/prx/libScePngEnc/Export.cpp index 5c131543d..11aa6731b 100644 --- a/core/libs/prx/libScePngEnc/Export.cpp +++ b/core/libs/prx/libScePngEnc/Export.cpp @@ -1,28 +1,135 @@ +#include #include #include +#include +#include #include "SceTypes.hpp" #include "prx/libc/include/General.hpp" +#include "Decoder/Png.hpp" + +namespace { + +constexpr int PNG_ENC_ERROR_INVALID_ADDR = static_cast(0x80690101); +constexpr int PNG_ENC_ERROR_INVALID_SIZE = static_cast(0x80690102); +constexpr int PNG_ENC_ERROR_INVALID_PARAM = static_cast(0x80690103); +constexpr int PNG_ENC_ERROR_INVALID_HANDLE = static_cast(0x80690104); +constexpr int PNG_ENC_ERROR_DATA_OVERFLOW = static_cast(0x80690110); +constexpr int PNG_ENC_ERROR_FATAL = static_cast(0x80690120); + +constexpr std::uint32_t MAX_IMAGE_WIDTH = 1000000; +constexpr std::uint32_t MAX_IMAGE_HEIGHT = 1000000; +constexpr std::uint32_t MAX_FILTER_NUMBER = 5; +constexpr std::uint16_t COLOR_SPACE_RGB = 3; +constexpr std::uint16_t COLOR_SPACE_RGBA = 19; +constexpr std::uint16_t PIXEL_FORMAT_R8G8B8A8 = 0; +constexpr std::uint16_t PIXEL_FORMAT_B8G8R8A8 = 1; +constexpr std::uint16_t FILTER_SUB = 1; +constexpr std::uint16_t FILTER_UP = 2; +constexpr std::uint16_t FILTER_AVERAGE = 4; +constexpr std::uint16_t FILTER_PAETH = 8; +constexpr std::uint16_t FILTER_ALL = FILTER_SUB | FILTER_UP | FILTER_AVERAGE | FILTER_PAETH; +constexpr std::uint64_t CONTEXT_MAGIC = 0x434E45474E505341ull; + +struct PngEncContext { + std::uint64_t magic; + std::uint32_t maxImageWidth; + std::uint32_t reserved; +}; + +static_assert(sizeof(PngEncCreateParam) == 16); +static_assert(sizeof(PngEncEncodeParam) == 48); +static_assert(sizeof(PngEncOutputInfo) == 8); + +int validateCreateParam(const PngEncCreateParam* param) { + if (!param) return PNG_ENC_ERROR_INVALID_ADDR; + if (param->attribute != 0 || param->max_filter_number > MAX_FILTER_NUMBER) return PNG_ENC_ERROR_INVALID_PARAM; + if (param->max_image_width == 0 || param->max_image_width > MAX_IMAGE_WIDTH) return PNG_ENC_ERROR_INVALID_SIZE; + return 0; +} + +PngEncContext* context(void* handle) { + auto* ctx = static_cast(handle); + return ctx && ctx->magic == CONTEXT_MAGIC ? ctx : nullptr; +} + +int filterMode(std::uint16_t filterType) { + if (filterType == FILTER_ALL) return -1; + if (filterType & FILTER_SUB) return 1; + if (filterType & FILTER_UP) return 2; + if (filterType & FILTER_AVERAGE) return 3; + if (filterType & FILTER_PAETH) return 4; + return 0; +} + +} extern "C" { -int APS5_VABI scePngEncCreate() { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI scePngEncQueryMemorySize(const PngEncCreateParam* param) { + if (const int result = validateCreateParam(param); result != 0) return result; + return sizeof(PngEncContext); } -int APS5_VABI scePngEncDelete() { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI scePngEncCreate(const PngEncCreateParam* param, void* memoryAddress, uint32_t memorySize, void** handle) { + if (const int result = validateCreateParam(param); result != 0) return result; + if (!memoryAddress || !handle) return PNG_ENC_ERROR_INVALID_ADDR; + if (memorySize < sizeof(PngEncContext)) return PNG_ENC_ERROR_INVALID_SIZE; + *static_cast(memoryAddress) = {CONTEXT_MAGIC, param->max_image_width, 0}; + *handle = memoryAddress; + return 0; } -int APS5_VABI scePngEncEncode() { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI scePngEncDelete(void* handle) { + PngEncContext* ctx = context(handle); + if (!ctx) return PNG_ENC_ERROR_INVALID_HANDLE; + ctx->magic = 0; + return 0; } -int APS5_VABI scePngEncQueryMemorySize() { - NotImplemented_nid_no_patch(__func__); - return 0; +int APS5_VABI scePngEncEncode(void* handle, const PngEncEncodeParam* param, PngEncOutputInfo* outputInfo) { + const PngEncContext* ctx = context(handle); + if (!ctx) return PNG_ENC_ERROR_INVALID_HANDLE; + if (!param) return PNG_ENC_ERROR_INVALID_PARAM; + if (!param->image_mem_addr || !param->png_mem_addr) return PNG_ENC_ERROR_INVALID_ADDR; + if ((param->pixel_format != PIXEL_FORMAT_R8G8B8A8 && param->pixel_format != PIXEL_FORMAT_B8G8R8A8) + || (param->color_space != COLOR_SPACE_RGB && param->color_space != COLOR_SPACE_RGBA) || param->bit_depth != 8 + || param->clut_number != 0 || (param->filter_type & ~FILTER_ALL) != 0 || param->compression_level > 9) { + return PNG_ENC_ERROR_INVALID_PARAM; + } + + const std::uint32_t width = param->image_width; + const std::uint32_t height = param->image_height; + if (width == 0 || height == 0 || width > ctx->maxImageWidth || height > MAX_IMAGE_HEIGHT || param->png_mem_size == 0 + || param->image_pitch < width * 4 || static_cast(param->image_pitch) * (height - 1) + width * 4 > param->image_mem_size) { + return PNG_ENC_ERROR_INVALID_SIZE; + } + + const std::uint32_t channels = param->color_space == COLOR_SPACE_RGBA ? 4 : 3; + const bool bgr = param->pixel_format == PIXEL_FORMAT_B8G8R8A8; + std::vector pixels; + std::vector png; + try { + pixels.resize(static_cast(width) * height * channels); + std::uint8_t* destination = pixels.data(); + for (std::uint32_t y = 0; y < height; ++y) { + const std::uint8_t* source = param->image_mem_addr + static_cast(y) * param->image_pitch; + for (std::uint32_t x = 0; x < width; ++x, source += 4) { + *destination++ = source[bgr ? 2 : 0]; + *destination++ = source[1]; + *destination++ = source[bgr ? 0 : 2]; + if (channels == 4) *destination++ = source[3]; + } + } + png = Decoder::Png::Encode(pixels, width, height, channels, {param->compression_level, filterMode(param->filter_type)}); + } catch (const std::exception&) { + return PNG_ENC_ERROR_FATAL; + } + + const bool overflow = png.size() > param->png_mem_size; + if (outputInfo) *outputInfo = {overflow ? 0 : static_cast(png.size()), overflow ? 0 : height}; + if (overflow) return PNG_ENC_ERROR_DATA_OVERFLOW; + std::copy(png.begin(), png.end(), param->png_mem_addr); + return static_cast(png.size()); } } diff --git a/core/libs/tests/GuestPngEnc.cpp b/core/libs/tests/GuestPngEnc.cpp new file mode 100644 index 000000000..9208ae7f0 --- /dev/null +++ b/core/libs/tests/GuestPngEnc.cpp @@ -0,0 +1,118 @@ +#include "SceTypes.hpp" +#include "prx/libc/include/general/VabiMacros.hpp" +#include "Decoder/Png.hpp" +#include +#include +#include + +extern "C" { +int APS5_VABI scePngEncQueryMemorySize(const PngEncCreateParam*); +int APS5_VABI scePngEncCreate(const PngEncCreateParam*, void*, std::uint32_t, void**); +int APS5_VABI scePngEncDelete(void*); +int APS5_VABI scePngEncEncode(void*, const PngEncEncodeParam*, PngEncOutputInfo*); +} + +static void Require(bool value) { if (!value) std::abort(); } + +int main() { + constexpr int invalidAddr = static_cast(0x80690101); + constexpr int invalidSize = static_cast(0x80690102); + constexpr int invalidParam = static_cast(0x80690103); + constexpr int invalidHandle = static_cast(0x80690104); + constexpr int dataOverflow = static_cast(0x80690110); + constexpr std::uint32_t width = 5; + constexpr std::uint32_t height = 3; + constexpr std::uint32_t pitch = 24; + + PngEncCreateParam create{sizeof(PngEncCreateParam), 0, 16, 5}; + const int memorySize = scePngEncQueryMemorySize(&create); + Require(memorySize > 0); + Require(scePngEncQueryMemorySize(nullptr) == invalidAddr); + PngEncCreateParam bad = create; + bad.attribute = 1; + Require(scePngEncQueryMemorySize(&bad) == invalidParam); + bad = create; + bad.max_filter_number = 6; + Require(scePngEncQueryMemorySize(&bad) == invalidParam); + bad = create; + bad.max_image_width = 0; + Require(scePngEncQueryMemorySize(&bad) == invalidSize); + bad.max_image_width = 1000001; + Require(scePngEncQueryMemorySize(&bad) == invalidSize); + + std::vector memory((memorySize + 7) / 8); + void* handle = nullptr; + Require(scePngEncCreate(&create, nullptr, memorySize, &handle) == invalidAddr); + Require(scePngEncCreate(&create, memory.data(), memorySize - 1, &handle) == invalidSize); + Require(scePngEncCreate(&create, memory.data(), memorySize, &handle) == 0 && handle == memory.data()); + + std::vector image(pitch * height, 0xEE); + for (std::uint32_t y = 0; y < height; ++y) { + for (std::uint32_t x = 0; x < width; ++x) { + std::uint8_t* pixel = &image[y * pitch + x * 4]; + pixel[0] = static_cast(x * 40); + pixel[1] = static_cast(y * 80); + pixel[2] = static_cast(200 - x * 10); + pixel[3] = static_cast(100 + x + y); + } + } + std::vector png(4096); + PngEncEncodeParam encode{image.data(), png.data(), static_cast(image.size()), static_cast(png.size()), + width, height, pitch, 0, 19, 8, 0, 15, 6}; + + for (const std::uint16_t pixelFormat : {0, 1}) { + for (const std::uint16_t colorSpace : {3, 19}) { + encode.pixel_format = pixelFormat; + encode.color_space = colorSpace; + PngEncOutputInfo info{}; + const int size = scePngEncEncode(handle, &encode, &info); + Require(size > 0 && info.data_size == static_cast(size) && info.processed_height == height); + const auto header = Decoder::Png::ParseHeader(std::span(png.data(), size)); + Require(header.has_value()); + Require(header->colorType == (colorSpace == 19 ? Decoder::Png::ColorType::Rgba : Decoder::Png::ColorType::Rgb)); + const auto decoded = Decoder::Png::Decode(std::span(png.data(), size)); + Require(decoded.has_value() && decoded->width == width && decoded->height == height); + for (std::uint32_t y = 0; y < height; ++y) { + for (std::uint32_t x = 0; x < width; ++x) { + const std::uint8_t* source = &image[y * pitch + x * 4]; + const std::uint8_t* result = &decoded->pixels[(y * width + x) * 4]; + Require(result[0] == source[pixelFormat == 1 ? 2 : 0] && result[1] == source[1]); + Require(result[2] == source[pixelFormat == 1 ? 0 : 2]); + Require(result[3] == (colorSpace == 19 ? source[3] : 0xFF)); + } + } + } + } + + encode.png_mem_size = 20; + PngEncOutputInfo info{1, 1}; + Require(scePngEncEncode(handle, &encode, &info) == dataOverflow && info.data_size == 0 && info.processed_height == 0); + encode.png_mem_size = static_cast(png.size()); + + PngEncEncodeParam invalid = encode; + Require(scePngEncEncode(handle, nullptr, nullptr) == invalidParam); + invalid.image_mem_addr = nullptr; + Require(scePngEncEncode(handle, &invalid, nullptr) == invalidAddr); + for (auto change : {+[](PngEncEncodeParam& p) { p.pixel_format = 2; }, +[](PngEncEncodeParam& p) { p.color_space = 4; }, + +[](PngEncEncodeParam& p) { p.bit_depth = 16; }, +[](PngEncEncodeParam& p) { p.clut_number = 1; }, + +[](PngEncEncodeParam& p) { p.filter_type = 16; }, +[](PngEncEncodeParam& p) { p.compression_level = 10; }}) { + invalid = encode; + change(invalid); + Require(scePngEncEncode(handle, &invalid, nullptr) == invalidParam); + } + for (auto change : {+[](PngEncEncodeParam& p) { p.image_width = 0; }, +[](PngEncEncodeParam& p) { p.image_width = 17; }, + +[](PngEncEncodeParam& p) { p.image_pitch = 19; }, +[](PngEncEncodeParam& p) { p.image_mem_size = 67; }, + +[](PngEncEncodeParam& p) { p.png_mem_size = 0; }}) { + invalid = encode; + change(invalid); + Require(scePngEncEncode(handle, &invalid, nullptr) == invalidSize); + } + invalid = encode; + invalid.image_mem_size = pitch * (height - 1) + width * 4; + Require(scePngEncEncode(handle, &invalid, nullptr) > 0); + + Require(scePngEncDelete(handle) == 0); + Require(scePngEncDelete(handle) == invalidHandle); + Require(scePngEncEncode(handle, &encode, nullptr) == invalidHandle); + Require(scePngEncDelete(nullptr) == invalidHandle); +} diff --git a/docs/dev/TechnicalDebt.md b/docs/dev/TechnicalDebt.md index 7edcb5578..449c0a2d0 100644 --- a/docs/dev/TechnicalDebt.md +++ b/docs/dev/TechnicalDebt.md @@ -47,4 +47,5 @@ Throughout the project, every function at every stage either **does exactly what - `--to-intel` guest module trampolines are covered only by a synthetic relinker test; no game title has been verified with them on Linux or Windows. - [sceKeyboardGetKey2Char](../../core/libs/prx/libSceKeyboard/src/keyboard_impl.cpp) (libSceKeyboard) translates only the 101-key (US) arrangement and throws for the 106-key (Japanese) one; Ctrl and Alt do not change the character. - [libSceAudiodec](../../core/libs/prx/libSceAudiodec/Export.cpp) throws for the 24-bit PCM word size (`iBwPcm` 0): its sample layout is unknown. ATRAC9 decoding is covered only by configuration and error tests, as no ATRAC9 encoder is available for a fixture. +- [libScePngEnc](../../core/libs/prx/libScePngEnc/Export.cpp) honours `filter_type` only as all filters (adaptive) or a single filter: the [PNG encoder](../../core/Decoder/Png/src/Png.cpp) (stb) cannot restrict adaptive filtering to a subset, so the first filter in the mask is used. - [libScePlayerInvitationDialog](../../core/libs/prx/libScePlayerInvitationDialog/libScePlayerInvitationDialog.cpp) simulates dialog completion without displaying UI or sending invitations; its parameter ABI remains unverified.