From 3f5f84996a5f9c0589dcbff86e1429a33db1fde2 Mon Sep 17 00:00:00 2001 From: sebastian Date: Fri, 16 Oct 2026 15:59:24 +0200 Subject: [PATCH] feat(ajm): resample interleaved s16 PCM through codec 23 Broken Sword - Shadow of the Templars: Reforged (PPSA21480) decodes its audio itself and uses AJM codec 23 only to resample interleaved s16 PCM from 44.1 to 48 kHz: every block initializes the instance with 0, 2, 44100, sets the ratio 0.91875 and decodes 235 frames into a 256-frame output. The run threw "AJM: decoding codec 23 is not implemented". A codec 23 run now consumes its whole input and emits every output frame whose position lies inside it, with the Catmull-Rom of the existing resampler. Cases the title does not show throw, among them a run after a resampled run without a new initialize. --- core/libs/prx/libSceAjm.native/src/Ajm.cpp | 74 +++++++++++++- core/libs/tests/GuestAjm.cpp | 107 +++++++++++++++++++++ docs/dev/debt/libSceAjm.native.md | 2 +- 3 files changed, 178 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libSceAjm.native/src/Ajm.cpp b/core/libs/prx/libSceAjm.native/src/Ajm.cpp index 29fe39434ea..6819db59e03 100644 --- a/core/libs/prx/libSceAjm.native/src/Ajm.cpp +++ b/core/libs/prx/libSceAjm.native/src/Ajm.cpp @@ -46,6 +46,7 @@ constexpr std::int32_t AJM_RESULT_FATAL = static_cast(0x80000000u) constexpr std::uint32_t CODEC_MP3 = 0; constexpr std::uint32_t CODEC_AT9 = 1; constexpr std::uint32_t CODEC_M4AAC = 2; +constexpr std::uint32_t CODEC_PCM = 23; constexpr std::uint32_t CODEC_OPUS = 24; constexpr std::uint32_t M4AAC_CONFIG_ADTS = 1; @@ -166,6 +167,9 @@ struct Instance { std::uint32_t aacFrameSamples = 0; std::uint32_t aacDelayFrames = 0; bool aacHeaac = false; + std::uint32_t pcmChannels = 0; + std::uint32_t pcmSampleRate = 0; + bool pcmResampled = false; Resampler resampler; ~Instance() { @@ -392,8 +396,25 @@ void ClearResampler(Instance& instance) { instance.resampler.position = 1.0; } +std::int32_t InitializePcm(Instance& instance, const std::uint8_t* parameters, std::uint64_t size) { + if (size != 12) NotImplemented_nid_no_patch("sceAjmBatchJobInitialize (codec 23 parameters other than 12 bytes)"); + std::uint32_t words[3] = {}; + std::memcpy(words, parameters, sizeof(words)); + if (words[0] != 0) NotImplemented_nid_no_patch("sceAjmBatchJobInitialize (codec 23 input format other than 0, interleaved s16)"); + if (words[1] == 0 || words[1] > 8 || words[2] == 0) return AJM_RESULT_INVALID_PARAMETER; + instance.pcmChannels = words[1]; + instance.pcmSampleRate = words[2]; + instance.pcmResampled = false; + instance.initialized = true; + instance.totalDecodedSamples = 0; + instance.segmentSamples = 0; + instance.gapless = {}; + return 0; +} + std::int32_t InitializeInstance(Instance& instance, const std::uint8_t* parameters, std::uint64_t size) { ClearResampler(instance); + if (instance.codec == CODEC_PCM) return InitializePcm(instance, parameters, size); if (instance.codec == CODEC_OPUS) { if (size < 8) return AJM_RESULT_INVALID_PARAMETER; std::uint32_t channels = 0; @@ -565,6 +586,10 @@ void ResamplerPush(Instance& instance, const double* samples, std::size_t frames stored.insert(stored.end(), samples, samples + frames * channels); } +double CatmullRom(double p0, double p1, double p2, double p3, double t) { + return p1 + 0.5 * t * (p2 - p0 + t * (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3 + t * (3.0 * (p1 - p2) + p3 - p0))); +} + void ResamplerProduce(Instance& instance, PcmOutputs& outputs, std::size_t channels, std::uint32_t encoding, std::size_t sampleBytes) { auto& resampler = instance.resampler; if (resampler.frames.empty() || channels == 0) return; @@ -580,8 +605,7 @@ void ResamplerProduce(Instance& instance, PcmOutputs& outputs, std::size_t chann const double p1 = resampler.frames[index * channels + channel]; const double p2 = resampler.frames[(index + 1) * channels + channel]; const double p3 = resampler.frames[(index + 2) * channels + channel]; - const double value = p1 + 0.5 * t * (p2 - p0 + t * (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3 + t * (3.0 * (p1 - p2) + p3 - p0))); - StorePcm(value, frame.data() + channel * sampleBytes, encoding); + StorePcm(CatmullRom(p0, p1, p2, p3, t), frame.data() + channel * sampleBytes, encoding); } outputs.Emit(frame.data(), frameBytes); resampler.position += resampler.ratio; @@ -628,6 +652,7 @@ SidebandFormat CurrentFormat(const Instance& instance) { case CODEC_MP3: return {instance.mp3Channels, ChannelMask(instance.mp3Channels), instance.mp3SampleRate, PcmEncoding(instance), instance.mp3Bitrate, 0}; case CODEC_OPUS: return {instance.opusChannels, ChannelMask(instance.opusChannels), instance.opusSampleRate, PcmEncoding(instance), 0, 0}; case CODEC_M4AAC: return {instance.aacChannels, ChannelMask(instance.aacChannels), instance.aacSampleRate, PcmEncoding(instance), instance.aacBitrate, 0}; + case CODEC_PCM: return {instance.pcmChannels, ChannelMask(instance.pcmChannels), instance.pcmSampleRate, PcmEncoding(instance), 0, 0}; default: throw std::runtime_error("AJM: the format of codec " + std::to_string(instance.codec) + " is not implemented"); } } @@ -1127,6 +1152,44 @@ void OpenOpusFromPacket(Instance& instance, const std::uint8_t* data, std::size_ OpenOpus(instance); } +void RunPcm(Instance& instance, const JobHeader& job, const AjmBuffer* inputs, const AjmBuffer* outputs) { + if (job.flags & ~SIDEBAND_STREAM) NotImplemented_nid_no_patch("AJM codec 23 run with flags other than SIDEBAND_STREAM"); + const auto encoding = PcmEncoding(instance); + if (encoding > 2) throw std::runtime_error("AJM codec 23: PCM encoding " + std::to_string(encoding) + " is not implemented"); + if (instance.resampler.change != 0.0f) NotImplemented_nid_no_patch("AJM codec 23 run with a resample ratio change per sample"); + if (instance.gapless.totalSamples != 0 || instance.gapless.skipSamples != 0) NotImplemented_nid_no_patch("AJM codec 23 run with gapless decode counts"); + if (instance.pcmResampled) NotImplemented_nid_no_patch("AJM codec 23 run after a resampled run without an initialize or context clear in between"); + const JobInput input(job, inputs); + PcmOutputs pcmOutputs(outputs, job.outputCount); + const std::size_t channels = instance.pcmChannels; + const std::size_t inputFrameBytes = channels * sizeof(std::int16_t); + const std::size_t sampleBytes = encoding == 0 ? sizeof(std::int16_t) : sizeof(std::int32_t); + if (input.empty()) NotImplemented_nid_no_patch("AJM codec 23 run with an empty input"); + if (input.size() % inputFrameBytes != 0) NotImplemented_nid_no_patch("AJM codec 23 run with an input that is not a whole number of frames"); + const std::size_t frames = input.size() / inputFrameBytes; + const double ratio = instance.resampler.ratio; + const std::size_t room = pcmOutputs.Room() / (channels * sampleBytes); + std::size_t outputFrames = 0; + while (outputFrames < room && static_cast(outputFrames) * ratio < static_cast(frames)) ++outputFrames; + if (static_cast(outputFrames) * ratio < static_cast(frames)) NotImplemented_nid_no_patch("AJM codec 23 run whose output cannot hold the whole resampled input"); + const auto sample = [&](std::size_t index, std::size_t channel) { return PcmValue(input.data() + (std::min(index, frames - 1) * channels + channel) * sizeof(std::int16_t), 0); }; + std::vector frame(channels * sampleBytes); + for (std::size_t output = 0; output < outputFrames; ++output) { + const double position = static_cast(output) * ratio; + const auto index = static_cast(position); + const double t = position - static_cast(index); + for (std::size_t channel = 0; channel < channels; ++channel) { + const double value = CatmullRom(sample(index == 0 ? 0 : index - 1, channel), sample(index, channel), sample(index + 1, channel), sample(index + 2, channel), t); + StorePcm(value, frame.data() + channel * sampleBytes, encoding); + } + pcmOutputs.Emit(frame.data(), frame.size()); + } + if (ratio != 1.0) instance.pcmResampled = true; + instance.totalDecodedSamples += frames; + AJM_TRACE("[ajm] instance %u pcm run flags 0x%llx: %zu input bytes consumed, %zu output bytes, ratio %f -> produced %zu, %zu channels, total samples %llu\n", job.instance, static_cast(job.flags), input.size(), pcmOutputs.capacity, ratio, pcmOutputs.produced, channels, static_cast(instance.totalDecodedSamples)); + WriteRunSideband(job, instance, 0, input.size(), pcmOutputs.produced, 1, CurrentFormat(instance)); +} + void RunOpus(Instance& instance, const JobHeader& job, const AjmBuffer* inputs, const AjmBuffer* outputs) { const JobInput input(job, inputs); PcmOutputs pcmOutputs(outputs, job.outputCount); @@ -1439,6 +1502,7 @@ void ClearContext(Instance& instance) { instance.aacDelayFrames = AacDelayFrames(instance); } instance.opusPending.clear(); + instance.pcmResampled = false; ClearResampler(instance); } @@ -1476,7 +1540,7 @@ void Control(Instance& instance, const JobHeader& job, const AjmBuffer* inputs) } std::size_t HeldSamples(const Instance& instance) { - const std::size_t channels = instance.codec == CODEC_OPUS ? instance.opusChannels : instance.codec == CODEC_MP3 ? instance.mp3Channels : instance.codec == CODEC_M4AAC ? instance.aacChannels : static_cast(std::max(0, instance.info.channels)); + const std::size_t channels = instance.codec == CODEC_PCM ? instance.pcmChannels : instance.codec == CODEC_OPUS ? instance.opusChannels : instance.codec == CODEC_MP3 ? instance.mp3Channels : instance.codec == CODEC_M4AAC ? instance.aacChannels : static_cast(std::max(0, instance.info.channels)); if (Resampling(instance)) return ResamplerHeld(instance, channels); const std::size_t sampleBytes = PcmEncoding(instance) == 0 ? sizeof(std::int16_t) : sizeof(std::int32_t); return channels == 0 ? 0 : instance.opusPending.size() / (channels * sampleBytes); @@ -1542,7 +1606,7 @@ void Execute(const JobHeader& job, const AjmBuffer* inputs, const AjmBuffer* out if (!instance->initialized) { AJM_TRACE("[ajm] instance %u run before initialize\n", job.instance); WriteResult(job.sideband, job.sidebandSize, AJM_RESULT_NOT_INITIALIZED); - } else if (instance->codec != CODEC_AT9 && instance->codec != CODEC_MP3 && instance->codec != CODEC_M4AAC && instance->codec != CODEC_OPUS) { + } else if (instance->codec != CODEC_AT9 && instance->codec != CODEC_MP3 && instance->codec != CODEC_M4AAC && instance->codec != CODEC_PCM && instance->codec != CODEC_OPUS) { throw std::runtime_error("AJM: decoding codec " + std::to_string(instance->codec) + " is not implemented"); } else if ((job.flags & RUN_GET_CODEC_INFO) && (job.flags & RUN_MULTIPLE_FRAMES)) { NotImplemented_nid_no_patch("AJM run job with both RUN_GET_CODEC_INFO and RUN_MULTIPLE_FRAMES (sideband order)"); @@ -1557,6 +1621,8 @@ void Execute(const JobHeader& job, const AjmBuffer* inputs, const AjmBuffer* out RunMp3(*instance, job, inputs, outputs); } else if (instance->codec == CODEC_M4AAC) { RunAac(*instance, job, inputs, outputs); + } else if (instance->codec == CODEC_PCM) { + RunPcm(*instance, job, inputs, outputs); } else { RunOpus(*instance, job, inputs, outputs); } diff --git a/core/libs/tests/GuestAjm.cpp b/core/libs/tests/GuestAjm.cpp index 87d872eb74b..8053aae7945 100644 --- a/core/libs/tests/GuestAjm.cpp +++ b/core/libs/tests/GuestAjm.cpp @@ -1768,6 +1768,112 @@ void TestOpusGaplessReset(std::uint32_t context, std::size_t outputFrames, float Require(sceAjmInstanceDestroy(context, reference) == 0); } +struct PcmResults { + std::int64_t initialize[2] = {-1, -1}; + std::int64_t resample[2] = {-1, -1}; + DecodeSideband decode{}; +}; + +AjmBatchInfo PcmJobs(std::vector& batch, std::uint32_t instance, const std::uint32_t* parameters, float ratio, const std::vector& input, std::vector& output, PcmResults& results) { + AjmBatchInfo info{}; + Require(sceAjmBatchInitialize(batch.data(), batch.size(), &info) == 0); + if (parameters) Require(sceAjmBatchJobInitialize(&info, instance, parameters, 3 * sizeof(std::uint32_t), results.initialize) == 0); + if (ratio > 0) Require(sceAjmBatchJobSetResampleParameters(&info, instance, ratio, 1, results.resample) == 0); + Require(sceAjmBatchJobDecode(&info, instance, input.data(), input.size() * sizeof(std::int16_t), output.data(), output.size() * sizeof(std::int16_t), &results.decode) == 0); + return info; +} + +void TestPcmResample(std::uint32_t context) { + constexpr std::uint64_t flags = 0x1002; + constexpr std::int16_t untouched = 0x7FFF; + const std::uint32_t parameters[3] = {0, 2, 44100}; + constexpr std::size_t inputFrames = 235; + constexpr float ratio = 0.91875f; + std::vector input(inputFrames * 2); + for (std::size_t frame = 0; frame < inputFrames; ++frame) { + input[frame * 2] = static_cast(frame * 100); + input[frame * 2 + 1] = static_cast((static_cast(frame * 7919 % 2001) - 1000) * 20); + } + const auto at = [&](std::ptrdiff_t frame, std::size_t channel) { + const auto clamped = std::clamp(frame, 0, static_cast(inputFrames) - 1); + return static_cast(input[static_cast(clamped) * 2 + channel]); + }; + std::vector batch(4096); + + std::uint32_t instance = 0; + Require(sceAjmInstanceCreate(context, 23, flags, &instance) == 0); + for (int block = 0; block < 2; ++block) { + std::vector output(300 * 2, untouched); + PcmResults results; + Submit(context, PcmJobs(batch, instance, parameters, ratio, input, output, results)); + Require(results.initialize[0] == 0 && results.resample[0] == 0 && results.decode.result == 0); + Require(results.decode.inputConsumed == 940 && results.decode.outputWritten == 1024 && results.decode.totalDecodedSamples == 235); + for (std::size_t frame = 0; frame < 256; ++frame) { + const double position = static_cast(frame) * static_cast(ratio); + const auto index = static_cast(position); + const double t = position - static_cast(index); + for (std::size_t channel = 0; channel < 2; ++channel) { + const double p0 = at(index - 1, channel), p1 = at(index, channel), p2 = at(index + 1, channel), p3 = at(index + 2, channel); + const double expected = std::clamp(p1 + 0.5 * t * (p2 - p0 + t * (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3 + t * (3.0 * (p1 - p2) + p3 - p0))), -32768.0, 32767.0); + Require(std::fabs(output[frame * 2 + channel] - expected) <= 1.0); + } + } + Require(std::all_of(output.begin() + 256 * 2, output.end(), [&](std::int16_t value) { return value == untouched; })); + } + { + std::vector output(256 * 2); + PcmResults results; + Require(Refused(context, PcmJobs(batch, instance, nullptr, ratio, input, output, results))); + } + { + std::vector output(255 * 2); + PcmResults results; + Require(Refused(context, PcmJobs(batch, instance, parameters, ratio, input, output, results))); + } + { + const GaplessDecode gapless{100, 0, 0}; + std::int32_t gaplessResult[2] = {-1, -1}; + std::vector output(256 * 2); + PcmResults results; + AjmBatchInfo info{}; + Require(sceAjmBatchInitialize(batch.data(), batch.size(), &info) == 0); + Require(sceAjmBatchJobInitialize(&info, instance, parameters, sizeof(parameters), results.initialize) == 0); + Require(sceAjmBatchJobSetGaplessDecode(&info, instance, &gapless, 1, gaplessResult) == 0); + Require(sceAjmBatchJobDecode(&info, instance, input.data(), input.size() * sizeof(std::int16_t), output.data(), output.size() * sizeof(std::int16_t), &results.decode) == 0); + Require(Refused(context, info)); + } + Require(sceAjmInstanceDestroy(context, instance) == 0); + + std::uint32_t plain = 0; + Require(sceAjmInstanceCreate(context, 23, flags, &plain) == 0); + for (int run = 0; run < 2; ++run) { + std::vector output(input.size() + 4, untouched); + PcmResults results; + Submit(context, PcmJobs(batch, plain, run == 0 ? parameters : nullptr, 0, input, output, results)); + Require(results.decode.result == 0 && results.decode.inputConsumed == 940 && results.decode.outputWritten == 940); + Require(results.decode.totalDecodedSamples == 235u * static_cast(run + 1)); + Require(std::equal(input.begin(), input.end(), output.begin()) && output[input.size()] == untouched); + } + Require(sceAjmInstanceDestroy(context, plain) == 0); + + std::uint32_t invalid = 0; + Require(sceAjmInstanceCreate(context, 23, flags, &invalid) == 0); + { + const std::uint32_t noChannels[3] = {0, 0, 44100}; + std::vector output(256 * 2); + PcmResults results; + Submit(context, PcmJobs(batch, invalid, noChannels, 0, input, output, results)); + Require(results.initialize[0] == 4 && results.decode.result == 1); + } + { + const std::uint32_t otherFormat[3] = {1, 2, 44100}; + std::vector output(256 * 2); + PcmResults results; + Require(Refused(context, PcmJobs(batch, invalid, otherFormat, 0, input, output, results))); + } + Require(sceAjmInstanceDestroy(context, invalid) == 0); +} + } int main() { @@ -1839,5 +1945,6 @@ int main() { TestResampleAt9(context); TestAt9GaplessSegments(context); TestAt9RunDecodesWholeInput(context); + TestPcmResample(context); Require(sceAjmFinalize(context) == 0); } diff --git a/docs/dev/debt/libSceAjm.native.md b/docs/dev/debt/libSceAjm.native.md index bcdfde9b1e4..ca2298edef0 100644 --- a/docs/dev/debt/libSceAjm.native.md +++ b/docs/dev/debt/libSceAjm.native.md @@ -8,7 +8,7 @@ - [sceAjmDecMp3ParseFrame](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - signature, result layout and rate tables taken from the [shadPS4](https://github.com/shadps4-emu/shadPS4) reimplementation of the PS4 library, not confirmed on a PS5 title: the layer field is not checked and MPEG-2.5 stops at 64 kbps; the original file length (`parse_ofl`) layout (Xing/Info + LAME tag, VBRI, Fraunhofer `0xB4` block with its CRC, encoder delay = samples per frame + LAME delay + 529) also comes from shadPS4 only, and the Fraunhofer block has no other public description - [sceAjmBatchJobGetCodecInfo, sceAjmBatchJobGetGaplessDecode](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - the `RUN_GET_CODEC_INFO` flag (0x800), the ATRAC9 codec info layout and its next frame size are taken from the [shadPS4](https://github.com/shadps4-emu/shadPS4) and [RPCSX](https://github.com/RPCSX/rpcsx) reimplementations, not confirmed on a PS5 title; a run job without buffers returns result 0, as in shadPS4. The gapless sideband reports the total and skip counts the title set and does not count them down: RPCSX does the same, shadPS4 counts them down and BryKytyPS5 returns its current counts - [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 decoders other than MP3, ATRAC9, M4AAC and Opus](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - decoding throws for every other codec id. Broken Sword - Shadow of the Templars: Reforged (PPSA21480) stops at codec 23 (`AJM: decoding codec 23 is not implemented`). The ids known here are MP3 0, ATRAC9 1, M4AAC 2 and Opus 24; id 23 is not among them and which codec it is, is unknown. +- [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`; 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. - [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); other flags throw - [sceAjmBatchJobControl](../../../core/libs/prx/libSceAjm.native/src/Ajm.cpp) (libSceAjm.native) - RESET is assumed to clear the context as sceAjmBatchJobClearContext does