feat(libSceContentExport): implement the export session calls

sceContentExportStart hands out positive session ids, at most 10 live
(SharpProspero MaxExecution), and sceContentExportFinish closes them.
sceContentExportGetProgress, sceContentExportCancel and
sceContentExportFromDataWithThumbnail take their Orbital signatures and
check the session. The host has no media library to export into, so
FromDataWithThumbnail throws before it calls either data callback, and
GetProgress and Cancel throw for a live session since no export ever
runs and their result for an idle session is unknown. An eleventh Start
and Term with live sessions throw.

Titles: Cult of the Lamb PPSA06465 imports GetProgress, Cancel and
FromDataWithThumbnail (#2686); PPSA21564 (#271) and Cyberpunk 2077
PPSA04029 (#266) import Start.

Sources: Orbital tools/ida/db_types.json (cc408367) for the signatures,
SharpProspero Interop/Content/ContentExport.cs (5dfdb4b0) for the
session model and MaxExecution, shadPS4 content_export.h (443f0224) for
the parameter and callback layouts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 473d6dd355d2c0ee22f9792927e0e465846569cf)
This commit is contained in:
David 2026-10-11 11:07:42 +02:00 committed by Adrià Franch
parent bb48d60fc6
commit 6ce1240aba
6 changed files with 102 additions and 24 deletions

View File

@ -1590,6 +1590,19 @@ struct ContentExportInitParam2 {
std::int64_t reserved1;
};
struct ContentExportParam {
char title[257];
char comment[257];
char content_type[65];
};
static_assert(sizeof(ContentExportParam) == 579);
struct ContentExportCallbackParam {
std::size_t content_length;
void* func;
void* user_data;
};
struct ContentSearchInitParam { std::size_t memory_size; };
struct PngEncCreateParam {

View File

@ -15,7 +15,7 @@ if(BUILD_TESTING)
add_test_executable(guest_content_unimplemented_tests ${LIBS_INCLUDE_DIR}/tests/GuestContentUnimplemented.cpp)
target_include_directories(guest_content_unimplemented_tests PRIVATE ${LIBS_INCLUDE_DIR})
target_link_libraries(guest_content_unimplemented_tests PRIVATE libSceContentExport libSceContentSearch libSceContentDelete libc)
target_link_libraries(guest_content_unimplemented_tests PRIVATE libSceContentSearch libSceContentDelete libc)
configure_windows_unwind(guest_content_unimplemented_tests)
add_test(NAME guest_content_unimplemented COMMAND guest_content_unimplemented_tests)
if(WIN32)

View File

@ -3,11 +3,23 @@
#include "SceTypes.hpp"
#include "prx/libc/include/General.hpp"
#include <atomic>
#include <limits>
#include <mutex>
#include <set>
#include <stdexcept>
#include <string>
namespace {
constexpr std::size_t MaxExecution = 10;
std::atomic<bool> g_initialized{false};
std::mutex g_sessionMutex;
std::set<std::int32_t> g_sessions;
std::int32_t g_lastSessionId = 0;
void RequireSession(const char* function, std::int32_t expId) {
if (!g_initialized.load()) throw std::logic_error(std::string(function) + ": not initialized");
if (!g_sessions.contains(expId)) throw std::invalid_argument(std::string(function) + ": unknown export session");
}
}
extern "C" {
@ -21,9 +33,11 @@ int APS5_VABI sceContentExportInit2(const ContentExportInitParam2* init_param) {
return 0;
}
int APS5_VABI sceContentExportFinish(void) {
NotImplemented_nid_no_patch(__func__);
return 0;
int APS5_VABI sceContentExportFinish(std::int32_t expId) {
std::lock_guard lock(g_sessionMutex);
RequireSession(__func__, expId);
g_sessions.erase(expId);
return 0;
}
int APS5_VABI sceContentExportFromData(void) {
@ -42,26 +56,36 @@ int APS5_VABI sceContentExportFromFileWithThumbnail(void) {
}
int APS5_VABI sceContentExportStart(void) {
NotImplemented_nid_no_patch(__func__);
return 0;
std::lock_guard lock(g_sessionMutex);
if (!g_initialized.load()) throw std::logic_error(std::string(__func__) + ": not initialized");
if (g_sessions.size() >= MaxExecution) throw std::runtime_error(std::string(__func__) + ": behaviour beyond 10 live export sessions is unknown");
if (g_lastSessionId == std::numeric_limits<std::int32_t>::max()) throw std::runtime_error(std::string(__func__) + ": export session ids exhausted");
g_sessions.insert(++g_lastSessionId);
return g_lastSessionId;
}
int APS5_VABI sceContentExportGetProgress(void) {
NotImplemented_nid_no_patch(__func__);
return 0;
int APS5_VABI sceContentExportGetProgress(std::int32_t expId) {
std::lock_guard lock(g_sessionMutex);
RequireSession(__func__, expId);
throw std::runtime_error(std::string(__func__) + ": progress of a session with no running export is unknown");
}
int APS5_VABI sceContentExportCancel(void) {
NotImplemented_nid_no_patch(__func__);
return 0;
int APS5_VABI sceContentExportCancel(std::int32_t expId) {
std::lock_guard lock(g_sessionMutex);
RequireSession(__func__, expId);
throw std::runtime_error(std::string(__func__) + ": cancelling a session with no running export is unknown");
}
int APS5_VABI sceContentExportFromDataWithThumbnail(void) {
NotImplemented_nid_no_patch(__func__);
return 0;
int APS5_VABI sceContentExportFromDataWithThumbnail(std::int32_t expId, const ContentExportParam* param, const ContentExportCallbackParam* contentCallbackParam,
const char* thumbnailType, const ContentExportCallbackParam* thumbnailCallbackParam, char* path, std::size_t pathBufferLength) {
std::lock_guard lock(g_sessionMutex);
RequireSession(__func__, expId);
throw std::runtime_error(std::string(__func__) + ": the host has no media library to export into");
}
int APS5_VABI sceContentExportTerm(void) {
std::lock_guard lock(g_sessionMutex);
if (!g_sessions.empty()) throw std::runtime_error(std::string(__func__) + ": terminating with live export sessions is unknown");
bool expected = true;
if (!g_initialized.compare_exchange_strong(expected, false)) throw std::logic_error(std::string(__func__) + ": not initialized");
return 0;

View File

@ -1,25 +1,41 @@
#include "SceTypes.hpp"
#include <cstdint>
#include <cstdlib>
#include <set>
#include <stdexcept>
extern "C" int APS5_VABI sceContentExportInit2(const ContentExportInitParam2* init_param);
extern "C" int APS5_VABI sceContentExportTerm(void);
extern "C" int APS5_VABI sceContentExportStart(void);
extern "C" int APS5_VABI sceContentExportFinish(std::int32_t expId);
extern "C" int APS5_VABI sceContentExportGetProgress(std::int32_t expId);
extern "C" int APS5_VABI sceContentExportCancel(std::int32_t expId);
extern "C" int APS5_VABI sceContentExportFromDataWithThumbnail(std::int32_t expId, const ContentExportParam* param, const ContentExportCallbackParam* contentCallbackParam,
const char* thumbnailType, const ContentExportCallbackParam* thumbnailCallbackParam, char* path, std::size_t pathBufferLength);
namespace {
void Require(bool value) { if (!value) std::abort(); }
template <typename TAction>
template <typename TException = std::logic_error, typename TAction>
bool Throws(TAction action) {
try {
action();
} catch (const std::logic_error&) {
} catch (const TException&) {
return true;
}
return false;
}
int allocator = 0;
char path[1024]{};
void Export(std::int32_t expId) {
ContentExportParam param{};
ContentExportCallbackParam content{};
ContentExportCallbackParam thumbnail{};
sceContentExportFromDataWithThumbnail(expId, &param, &content, "image/png", &thumbnail, path, sizeof(path));
}
}
@ -27,6 +43,8 @@ int main() {
ContentExportInitParam2 param{&allocator, &allocator, nullptr, 0x10000, 0, 0};
Require(Throws([] { sceContentExportInit2(nullptr); }));
Require(Throws([] { sceContentExportTerm(); }));
Require(Throws([] { sceContentExportStart(); }));
Require(Throws([] { sceContentExportFinish(1); }));
ContentExportInitParam2 missing = param;
missing.free_func = nullptr;
Require(Throws([&] { sceContentExportInit2(&missing); }));
@ -38,5 +56,33 @@ int main() {
Require(sceContentExportTerm() == 0);
Require(Throws([] { sceContentExportTerm(); }));
Require(sceContentExportInit2(&param) == 0);
std::set<int> sessions;
for (int i = 0; i < 10; ++i) {
int session = sceContentExportStart();
Require(session > 0);
Require(sessions.insert(session).second);
}
Require(Throws<std::runtime_error>([] { sceContentExportStart(); }));
Require(Throws<std::runtime_error>([] { sceContentExportTerm(); }));
int first = *sessions.begin();
Require(Throws<std::runtime_error>([&] { sceContentExportGetProgress(first); }));
Require(Throws<std::runtime_error>([&] { sceContentExportCancel(first); }));
Require(Throws<std::runtime_error>([&] { Export(first); }));
Require(path[0] == 0);
Require(sceContentExportFinish(first) == 0);
Require(Throws<std::invalid_argument>([&] { sceContentExportFinish(first); }));
Require(Throws<std::invalid_argument>([&] { sceContentExportGetProgress(first); }));
Require(Throws<std::invalid_argument>([&] { sceContentExportCancel(first); }));
Require(Throws<std::invalid_argument>([&] { Export(first); }));
Require(Throws<std::invalid_argument>([] { sceContentExportFinish(0); }));
Require(Throws<std::invalid_argument>([] { sceContentExportFinish(-1); }));
sessions.erase(first);
int reopened = sceContentExportStart();
Require(reopened > 0 && reopened != first);
Require(sessions.insert(reopened).second);
for (int session : sessions) Require(sceContentExportFinish(session) == 0);
Require(sceContentExportTerm() == 0);
Require(Throws([] { sceContentExportStart(); }));
Require(Throws([&] { sceContentExportFinish(reopened); }));
}

View File

@ -6,9 +6,6 @@
extern "C" {
int APS5_VABI sceContentDeleteById();
int APS5_VABI sceContentExportGetProgress();
int APS5_VABI sceContentExportCancel();
int APS5_VABI sceContentExportFromDataWithThumbnail();
int APS5_VABI sceContentSearchGetContentLastUpdateId();
int APS5_VABI sceContentSearchOpenMetadataByContentId();
int APS5_VABI sceContentSearchGetTotalContentSize();
@ -34,9 +31,6 @@ bool ReportsNotImplemented(const char* name, int (APS5_VABI *function)()) {
int main() {
bool correct = true;
correct &= ReportsNotImplemented("sceContentDeleteById", sceContentDeleteById);
correct &= ReportsNotImplemented("sceContentExportGetProgress", sceContentExportGetProgress);
correct &= ReportsNotImplemented("sceContentExportCancel", sceContentExportCancel);
correct &= ReportsNotImplemented("sceContentExportFromDataWithThumbnail", sceContentExportFromDataWithThumbnail);
correct &= ReportsNotImplemented("sceContentSearchGetContentLastUpdateId", sceContentSearchGetContentLastUpdateId);
correct &= ReportsNotImplemented("sceContentSearchOpenMetadataByContentId", sceContentSearchOpenMetadataByContentId);
correct &= ReportsNotImplemented("sceContentSearchGetTotalContentSize", sceContentSearchGetTotalContentSize);

View File

@ -135,7 +135,7 @@ Throughout the project, every function at every stage either **does exactly what
- [sceSaveDataCancel](../../core/libs/prx/libSceSaveData.native/Export.cpp) (libSceSaveData.native) - imported by PPSA23126, which fails to load without it; added without an implementation because its signature is unknown. Every save data call here finishes before it returns, so nothing is ever in flight to cancel, but what the console returns then is unknown
- [sceAudioOutExClose, sceAudioOutExConfigureOutput, sceAudioOutExGetMonitorInfo, sceAudioOutExOpen](../../core/libs/prx/libSceAudioOut/src/AudioOut.cpp) (libSceAudioOut) - imported by PPSA99505, which fails to load without them; added without an implementation and declared without parameters. PPSA99505 calls them only for its HDMI bitstream output, an option that is off by default, to read the formats the TV or receiver lists, switch the HDMI output mode and open a port for IEC 61937 bursts ([Jelly5](https://github.com/02dnot/Jelly5/blob/552b866d89b2a58b27f7f7b0484dc743ce235526/app/src/jelly5_bitstream.cpp#L110-L179)); [EVO-PLAYER-PS5](https://github.com/sainsaji/EVO-PLAYER-PS5/blob/da0c0451333bab82e79fffd2de4718e917f9dcb0/projects/evoplayer/core/src/audio/evo_pt.cpp) also calls `sceAudioOutExOpen`, `sceAudioOutExConfigureOutput` and `sceAudioOutExClose` for HDMI bitstream output. The two declare the arguments of `sceAudioOutExConfigureOutput` differently and neither is confirmed on a console. Audio reaches the host device through SDL as PCM, so there is no encoded HDMI output to switch to
- [sceOpusCeltDecCreateEx, sceOpusCeltDecDecode, sceOpusCeltDecDestroy, sceOpusCeltDecGetSize, sceOpusCeltDecInitialize, sceOpusCeltDecTerminate](../../core/libs/prx/libSceOpusCeltDec/Export.cpp) (libSceOpusCeltDec) - imported by PPSA99001, which fails to load without the module; added without an implementation. Their signatures match libSceOpusDec's in [ProsperoRadio](https://github.com/blackbearreloaded/ProsperoRadio/blob/2c4029c3a8f43e9e1dfef98374d94d99b14d745d/src/opus_decoder.cpp). PPSA99001 opens this decoder only as a fallback when libSceOpusDec reports a retryable error for a CELT packet, which [libSceOpusDec](../../core/libs/prx/libSceOpusDec/Export.cpp) here never returns, so the fallback is not reached
- [sceContentDeleteById](../../core/libs/prx/libSceContentDelete/Export.cpp) (libSceContentDelete), [sceContentExportGetProgress, sceContentExportCancel, sceContentExportFromDataWithThumbnail](../../core/libs/prx/libSceContentExport/Export.cpp) (libSceContentExport) and [sceContentSearchGetContentLastUpdateId, sceContentSearchOpenMetadataByContentId, sceContentSearchGetTotalContentSize, sceContentSearchGetNumOfContent](../../core/libs/prx/libSceContentSearch/Export.cpp) (libSceContentSearch) - imported by PPSA06465, which fails to load without them; added without an implementation because their signatures are unknown
- [sceContentDeleteById](../../core/libs/prx/libSceContentDelete/Export.cpp) (libSceContentDelete) and [sceContentSearchGetContentLastUpdateId, sceContentSearchOpenMetadataByContentId, sceContentSearchGetTotalContentSize, sceContentSearchGetNumOfContent](../../core/libs/prx/libSceContentSearch/Export.cpp) (libSceContentSearch) - imported by PPSA06465, which fails to load without them; added without an implementation because their signatures are unknown
- [sce::Json::Parser::parse with a read callback](../../core/libs/prx/libSceJson2/Export.cpp) (libSceJson2) - the callback's contract is taken from PPSA04540's: it returns 0 with the next character, and at the end of its data writes `'\0'` and returns -1. Characters are read until the callback returns non-zero or gives `'\0'`, and the text read is then parsed as by the `(text, size)` overload, so trailing characters after the value are rejected as there. Whether the console stops reading at the end of the top-level value, and what it returns when the callback fails before the end, are unknown
- [sce::Json::Parser::parse](../../core/libs/prx/libSceJson2/Export.cpp) (libSceJson2) - a number that overflows a double (e.g. `1e309`) throws: whether the console rejects the document or stores an infinite real is unknown
- [sceUserServiceGetNpAccountId](../../core/libs/prx/libSceUserService/Export.cpp) (libSceUserService) - the account id for a user with no linked PSN account is assumed to be 0 with success; libSceNpManager's `sceNpGetAccountIdA` answers the same question with `SCE_NP_ERROR_SIGNED_OUT`
@ -408,6 +408,7 @@ Throughout the project, every function at every stage either **does exactly what
- [sceKernelAddReadEvent and sceKernelAddWriteEvent](../../core/libs/prx/libkernel/Equeue/Equeue.cpp) (libkernel): a write event reports the socket's send buffer size (`SO_SNDBUF`) in `data`, not the free space left in it, as Windows has no query for that space. Only guest sockets are supported; other descriptors and a low watermark above 1 throw
- [sceNetSendmsg and sceNetRecvmsg](../../core/libs/prx/libSceNet/Export.cpp) (libSceNet) copy the scatter/gather list through one host buffer and accept only the flags `sceNetSend`/`sceNetRecv` accept. Sending control data throws; a receive never returns control data (`msg_controllen` 0), since no socket option that queues it is supported
- [Additional content](../../core/libs/prx/libSceAppContent/Export.cpp) (libSceAppContent) is not installed or mounted: `sceAppContentAddcontMount` answers `NOT_FOUND` for every entitlement label and `sceAppContentAddcontUnmount` for every mount point, as no add-content mount is ever handed out; `sceAppContentGetAddcontInfo` answers `DRM_NO_ENTITLEMENT` for every label and `sceAppContentGetAddcontInfoList` reports 0 entries
- [Content export sessions](../../core/libs/prx/libSceContentExport/Export.cpp) (libSceContentExport): `sceContentExportStart` hands out positive session ids, at most 10 live, which `sceContentExportFinish` closes. The signatures follow [Orbital](https://github.com/AlexAltea/orbital/blob/cc408367da6a6ebbf5ac1ac559fefeadc73b2831/tools/ida/db_types.json) and the limit `MaxExecution` [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/Content/ContentExport.cs); the parameter layouts follow [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/443f022450f6857256e780daa3d5a14029a748f7/src/core/libraries/content_export/content_export.h). The host has no media library, so `sceContentExportFromDataWithThumbnail` throws once the session is checked, before calling either data callback, and no export ever runs. `sceContentExportGetProgress` and `sceContentExportCancel` throw for a live session, as their result with no running export is unknown. An eleventh `sceContentExportStart` and `sceContentExportTerm` with live sessions throw: whether the console answers `EXECUTION_MAX` or `BUSY` there, or ends the sessions, is unverified. Session id values and error precedence are unverified
- [Runtime-loaded modules](../../core/relinker/relinker/src/guest/GuestModuleBuilder.cpp) (`--module-dir`) that another module needs are initialized at startup with no start arguments, before the module that needs them is loaded; the console is assumed to start them when that module is loaded.
- [sceKernelPread, sceKernelPwrite, sceKernelPreadv, sceKernelPwritev](../../core/libs/prx/libkernel/File/src/Stdio.cpp) (libkernel) on Windows transfer at the given offset with an overlapped `ReadFile`/`WriteFile` and restore the descriptor's file pointer afterwards, so a concurrent `read`/`write` on the same descriptor can see the moved pointer in between, unlike on the console. Handles that are not disk files return `ESPIPE`. `readv` on a pipe or console handle stops after the first iovec read, a short read the console is assumed to allow; each iovec is transferred separately, so a failure after the first transfer returns the bytes already done
- [sceKernelAioSubmitReadCommands, sceKernelAioSubmitWriteCommands and their Multiple variants](../../core/libs/prx/libkernel/Aio/src/Aio.cpp) (libkernel) on Windows transfer through the same positioned path as `sceKernelPread`/`sceKernelPwrite` and restore the descriptor's file pointer afterwards, so a concurrent `read`/`write` on the same descriptor can see the moved pointer in between, unlike on the console