From b1c5fba50c44996eb966043ce4f247b04b89ecee Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:24:45 +0000 Subject: [PATCH 1/2] fix(libSceAjm.native): reject a null batch buffer list with a nonzero count in sceAjmBatchJobRunSplit The null buffer list check in Append ran for every batch-job builder, although only sceAjmBatchJobRunSplit and sceAjmBatchJobControl pass a caller's buffer lists. It now runs in CheckedAppend, after the same invalid-parameter checks, for those two and for sceAjmBatchJobGetStatistics, whose zero-filled result has no source. The other builders append without it. No job is built or run differently. The guest test builds Initialize, ClearContext, SetGaplessDecode, SetResampleParameters, SetResampleParametersEx and GetResampleInfo jobs without buffers and runs them, and checks that RunSplit still throws for a null input or output list with a nonzero count without writing to the batch. --- core/libs/prx/libSceAjm.native/src/Ajm.cpp | 20 +++++++--- core/libs/tests/GuestAjm.cpp | 43 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libSceAjm.native/src/Ajm.cpp b/core/libs/prx/libSceAjm.native/src/Ajm.cpp index 195ee36a3a..60b2b3cbc2 100644 --- a/core/libs/prx/libSceAjm.native/src/Ajm.cpp +++ b/core/libs/prx/libSceAjm.native/src/Ajm.cpp @@ -231,10 +231,14 @@ struct PackedJob { }; static_assert(sizeof(PackedJob) == 32); -int Append(AjmBatchInfo* info, const JobHeader& header, const AjmBuffer* inputs, const AjmBuffer* outputs) { +int JobRejected(const AjmBatchInfo* info, const JobHeader& header) { if (!info || !info->p_buffer) return SCE_AJM_ERROR_INVALID_PARAMETER; if (header.inputCount > 0xffu || header.outputCount > 0xffu || header.parameterSize > sizeof(header.parameters) || header.sidebandSize > 0xffffffffu) return SCE_AJM_ERROR_INVALID_PARAMETER; - if ((header.inputCount && !inputs) || (header.outputCount && !outputs)) NotImplemented_nid_no_patch("AJM batch job with a null buffer list and a nonzero buffer count"); + return 0; +} + +int Append(AjmBatchInfo* info, const JobHeader& header, const AjmBuffer* inputs, const AjmBuffer* outputs) { + if (const int rejected = JobRejected(info, header)) return rejected; const std::size_t parameterBytes = (header.parameterSize + 7u) & ~std::size_t{7}; const std::size_t bytes = sizeof(PackedJob) + (header.inputCount + header.outputCount) * sizeof(AjmBuffer) + parameterBytes; if (info->offset > info->size || bytes > info->size - info->offset) return SCE_AJM_ERROR_OUT_OF_RESOURCES; @@ -251,6 +255,12 @@ int Append(AjmBatchInfo* info, const JobHeader& header, const AjmBuffer* inputs, return 0; } +int CheckedAppend(AjmBatchInfo* info, const JobHeader& header, const AjmBuffer* inputs, const AjmBuffer* outputs) { + if (const int rejected = JobRejected(info, header)) return rejected; + if ((header.inputCount && !inputs) || (header.outputCount && !outputs)) NotImplemented_nid_no_patch("AJM batch job with a null buffer list and a nonzero buffer count"); + return Append(info, header, inputs, outputs); +} + JobHeader MakeHeader(JobKind kind, std::uint32_t instance, void* sideband, std::uint64_t sidebandSize) { JobHeader header{}; header.kind = kind; @@ -1821,7 +1831,7 @@ int APS5_VABI sceAjmBatchJobControl(AjmBatchInfo* info, uint32_t instance, uint6 header.flags = flags; header.inputCount = sideband_input_size != 0 ? 1 : 0; const AjmBuffer input{const_cast(sideband_input), sideband_input_size}; - return Append(info, header, &input, nullptr); + return CheckedAppend(info, header, &input, nullptr); } int APS5_VABI sceAjmBatchJobRunSplit(AjmBatchInfo* info, uint32_t instance, uint64_t flags, const AjmBuffer* input_buffers, size_t input_buffers_num, const AjmBuffer* output_buffers, size_t output_buffers_num, void* sideband_output, size_t sideband_output_size) { @@ -1829,7 +1839,7 @@ int APS5_VABI sceAjmBatchJobRunSplit(AjmBatchInfo* info, uint32_t instance, uint header.flags = flags; header.inputCount = static_cast(input_buffers_num); header.outputCount = static_cast(output_buffers_num); - return Append(info, header, input_buffers, output_buffers); + return CheckedAppend(info, header, input_buffers, output_buffers); } int APS5_VABI sceAjmBatchJobRun(AjmBatchInfo* info, uint32_t instance, uint64_t flags, const void* data_input, size_t data_input_size, void* data_output, size_t data_output_size, void* sideband_output, size_t sideband_output_size) { @@ -1887,7 +1897,7 @@ int APS5_VABI sceAjmBatchJobGetCodecInfo(AjmBatchInfo* info, uint32_t instance, int APS5_VABI sceAjmBatchJobGetStatistics(AjmBatchInfo* info, float interval, void* result) { (void)interval; - return Append(info, MakeHeader(JobKind::GetStatistics, 0, result, 24), nullptr, nullptr); + return CheckedAppend(info, MakeHeader(JobKind::GetStatistics, 0, result, 24), nullptr, nullptr); } int APS5_VABI sceAjmBatchStart(uint32_t context, const AjmBatchInfo* info, int priority, AjmBatchError* error, uint32_t* batch) { diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index 6032301a75..9ceccaa809 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -1556,6 +1556,48 @@ ResampleInfo GetResampleInfo(std::uint32_t context, std::uint32_t instance) { return resample; } +void TestBuildersWithoutBuffers(std::uint32_t context) { + const auto instance = CreateOpus(context); + const std::uint32_t parameters[3] = {2, 48000, 0}; + const GaplessDecode gapless{2000, 100, 0}; + std::int64_t results[5] = {-1, -1, -1, -1, -1}; + ResampleInfo resample; + std::memset(&resample, 0xAA, sizeof(resample)); + std::vector batch(4096); + AjmBatchInfo info{}; + Require(sceAjmBatchInitialize(batch.data(), batch.size(), &info) == 0); + std::size_t offset = 0; + const auto built = [&](int status) { + const bool grew = status == 0 && info.offset > offset; + offset = info.offset; + return grew; + }; + Require(built(sceAjmBatchJobInitialize(&info, instance, parameters, sizeof(parameters), &results[0]))); + Require(built(sceAjmBatchJobClearContext(&info, instance, &results[1]))); + Require(built(sceAjmBatchJobSetGaplessDecode(&info, instance, &gapless, 1, &results[2]))); + Require(built(sceAjmBatchJobSetResampleParameters(&info, instance, 1.0f, 1, &results[3]))); + Require(built(sceAjmBatchJobSetResampleParametersEx(&info, instance, 1.0f, 0.0f, 1, &results[4]))); + Require(built(sceAjmBatchJobGetResampleInfo(&info, instance, &resample))); + Submit(context, info); + Require(std::all_of(std::begin(results), std::end(results), [](std::int64_t result) { return result == 0; })); + Require(resample.result == 0 && resample.internalResult == 0 && resample.ratio == 1.0f && resample.samples == 0); + + const auto nullListThrows = [&](std::size_t inputs, std::size_t outputs) { + const auto before = info.offset; + try { + sceAjmBatchJobRunSplit(&info, instance, 0, nullptr, inputs, nullptr, outputs, nullptr, 0); + } catch (const std::runtime_error&) { + return info.offset == before; + } + return false; + }; + Require(sceAjmBatchInitialize(batch.data(), batch.size(), &info) == 0); + Require(nullListThrows(1, 0) && nullListThrows(0, 1) && nullListThrows(2, 3)); + offset = 0; + Require(built(sceAjmBatchJobRunSplit(&info, instance, 0, nullptr, 0, nullptr, 0, nullptr, 0))); + Require(sceAjmInstanceDestroy(context, instance) == 0); +} + struct ResampledStream { std::vector pcm; std::size_t jobs = 0; @@ -2085,6 +2127,7 @@ int main() { TestResampleOpus(context); TestResampleMp3(context); TestResampleParametersEx(context); + TestBuildersWithoutBuffers(context); TestResampleAt9(context); TestAt9GaplessSegments(context); TestAt9RunDecodesWholeInput(context); From 46fe25248625ad9602bf6864201f98d49dc6e0ee Mon Sep 17 00:00:00 2001 From: Yunus Shaikh <75296019+EagleBearerYS@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:24:45 +0000 Subject: [PATCH 2/2] docs(debt): record that sceAjmBatchJobGetStatistics only zero-fills its sideband --- docs/dev/debt/libSceAjm.native.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index 8c0a9695c5..e14f4c3a1e 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -12,6 +12,7 @@ - [AJM Opus decoder](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native, codec 24) - the initialize parameter layout and little-endian `u16` packet byte count come from The Smurfs Dreams (`2, 48000, 0`) and Lost Soul Aside (`4, 48000, 1`). The third word is interpreted as the mapping family, as in [KytyPS5's initialize structure](https://github.com/KytyPS5/KytyPS5/blob/984cfcebd141b211aa9e0a8cde63b0ebe652d07a/src/libs/ajm/opus_decoder.h#L11-L15). Family 1 uses the conventional stream counts, coupled counts and channel map from [libopus 1.5.2's surround layout](https://github.com/xiph/opus/blob/v1.5.2/src/opus_multistream_encoder.c#L47-L55), passed to FFmpeg through an [RFC 7845 section 5.1](https://datatracker.ietf.org/doc/html/rfc7845#section-5.1) header with zero pre-skip and gain; gapless skipping remains controlled by AJM sideband parameters. FFmpeg returns PCM in its native speaker order. The implicit stream layout, PCM channel order and invalid family-0 channel count result are not verified on a console. NHL 27 (PPSA34063) passes `1, 48000, 255`. Family 255 with one channel is decoded as one mono stream, the same as family 0; this is assumed, since the stream count, coupled stream count and channel mapping that a family-255 Opus header carries ([RFC 7845 section 5.1.1](https://datatracker.ietf.org/doc/html/rfc7845#section-5.1.1)) are not passed. Only 48000 Hz, mapping families 0 and 1, and family 255 with one channel are implemented; other rates and families, and family 255 with more channels, throw - [AJM codec 23](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - taken as interleaved `s16` PCM that only passes through the resampler, from Broken Sword - Shadow of the Templars: Reforged (PPSA21480); no source names a codec 23 ([shadPS4's PS4 header](https://github.com/shadps4-emu/shadPS4/blob/5aa7ffa3f4f21bba827d7865ed8f69f98f993542/src/core/libraries/ajm/ajm.h#L19-L25) ends its codec list with `Max = 23`) and the name `CODEC_PCM` is ours. The title decodes its Ogg Opus and Vorbis audio itself and, for every block, initializes the instance (flags 0x1002) with `0, 2, 44100`, sets the ratio 0.91875 (44100/48000) and decodes 235 stereo frames into a 256-frame output. The 12 initialize bytes are taken as input format (0 is `s16`, other values throw), channel count and sample rate; the rate is only reported in the format sideband; other sizes throw; zero or more than 8 channels or a zero rate return the invalid-parameter result as for Opus, not verified. A run consumes its whole input and emits every output frame whose position (output index × ratio) is below the input frame count, with the 4-point Catmull-Rom of the other codecs' resampler and the first and last input frames repeated past the edges, so each run restarts at position 0; `totalDecodedSamples` counts input frames; `s32` and float outputs are accepted although only `s16` was seen. Throwing because the console's behaviour is unknown: a run after a resampled run without an initialize or context clear in between (whether the console carries the resampler position across runs), a ratio change per sample, gapless counts, an empty input or one that is not a whole number of frames, an output that cannot hold the resampled input, run flags other than `SIDEBAND_STREAM` and initialization through `sceAjmBatchJobControl` - [sceAjmBatchJobSetResampleParametersEx](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the `(info, instance, float ratio_start, float ratio_change_per_sample, flags, result)` signature is inferred from the name and from `sceAjmBatchJobSetResampleParameters`, and PPSA02225's one call agrees with that layout (`rdi`, `esi`, `xmm0` and `xmm1` as two single floats, `edx`, `rcx`) without showing `flags` or the float roles. With a zero change per sample it sets the ratio as `sceAjmBatchJobSetResampleParameters` does (Watch Dogs: Legion, PPSA01500, calls it so). How a non-zero change ramps the ratio is not confirmed on a console (no source: KytyPS5 ignores it, prosper stubs it, the PS5 firmware does not export the batch builders to a payload): it is assumed to be added to the ratio after each emitted output frame, clamped at zero, and kept across batches. RIDE 4 (PPSA01599) passes one call with a change of 4.6e-7, so the exact rule matters little there. +- [sceAjmBatchJobGetStatistics](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the job zero-fills the first 24 bytes of its sideband and ignores the interval; no source gives this function's result layout or values and no test covers it. [KytyPS5](https://github.com/KytyPS5/KytyPS5/blob/984cfcebd141b211aa9e0a8cde63b0ebe652d07a/src/libs/ajm.cpp#L973-L979) zero-fills a 48-byte result instead (the 8-byte result, a batch engine usage, three interval engine usages and six memory words), and [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/15abf68c9b52c4b04d094f2837719fdb871021f3/src/core/libraries/ajm/ajm.h#L147-L170) (PS4) names engine and memory statistics sidebands but not this builder - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - RESET (bit 13), INITIALIZE (bit 14) and the sideband input order (gapless decode, then initialize parameters) come from [shadPS4](https://github.com/shadps4-emu/shadPS4) (PS4); the one call in PPSA25936 passes the flags 0x4000 (bit 14 only), an 8-byte input and an 8-byte output, which agrees with INITIALIZE alone and the 8-byte parameters, without showing the codec or the meaning of the words; other flags throw - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - RESET is assumed to clear the context as sceAjmBatchJobClearContext does - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - initialize parameters are 8 bytes for ATRAC9 and M4AAC, 12 for Opus and none for MP3, as one title passes them; other codecs and sizes, and an output other than the 8-byte result, throw