feat(ngs2): implement legacy submixer UserFx callbacks

Invoke the title callback on planar mixed samples and silence. Preserve per-voice callback data, reset registration on setup, and release the render guard on callback failure. Include behavioral tests and source references. Written with AI assistance.
This commit is contained in:
TibixDev 2026-10-08 01:46:50 +03:00 committed by Adrià Franch
parent b1c5f10df8
commit 0d4db860a6
8 changed files with 177 additions and 2 deletions

View File

@ -2,7 +2,7 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/Ngs2Library.cmake)
add_sce_ngs2_library(libSceNgs2.native)
if(BUILD_TESTING)
foreach(ngs2Test IN ITEMS Render Atrac9 Filter Custom Parse Stream)
foreach(ngs2Test IN ITEMS Render Atrac9 Filter Custom Parse Stream UserFx)
string(TOLOWER ${ngs2Test} testName)
add_test_executable(ngs2_${testName}_tests ${CMAKE_CURRENT_SOURCE_DIR}/tests/Ngs2${ngs2Test}.cpp)
target_include_directories(ngs2_${testName}_tests PRIVATE ${LIBS_INCLUDE_DIR} ${CMAKE_SOURCE_DIR}/3rdparty/LibAtrac9/C/src)

View File

@ -49,6 +49,7 @@ static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_EXIT_LOOP = 0x100000
static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_PITCH = 0x10000005;
static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_FILTER = 0x1000000a;
static constexpr std::uint32_t SCE_NGS2_SUBMIXER_VOICE_PARAM_SETUP = 0x20000000;
static constexpr std::uint32_t SCE_NGS2_SUBMIXER_VOICE_PARAM_USER_FX = 0x20000004;
static constexpr std::uint32_t SCE_NGS2_MASTERING_VOICE_PARAM_SETUP = 0x30000000;
static constexpr std::uint32_t SCE_NGS2_MASTERING_VOICE_PARAM_GAIN = 0x30000004;
static constexpr std::uint32_t SCE_NGS2_MASTERING_VOICE_PARAM_OUTPUT = 0x30000005;
@ -173,6 +174,20 @@ struct Ngs2UserFx2SetupContext {
};
static_assert(sizeof(Ngs2UserFx2SetupContext) == 72);
struct Ngs2UserFxProcessContext {
float** channel_data;
std::uintptr_t user_data0;
std::uintptr_t user_data1;
std::uintptr_t user_data2;
std::uint32_t flags;
std::uint32_t num_channels;
std::uint32_t num_grain_samples;
std::uint32_t sample_rate;
};
static_assert(sizeof(Ngs2UserFxProcessContext) == 48);
using Ngs2UserFxProcessHandler = std::int32_t (APS5_VABI *)(Ngs2UserFxProcessContext*);
using Ngs2UserFx2CleanupContext = Ngs2UserFx2SetupContext;
struct Ngs2UserFx2ControlContext {
@ -406,6 +421,15 @@ struct Ngs2SubmixerVoiceSetupParam {
};
static_assert(sizeof(Ngs2SubmixerVoiceSetupParam) == 16);
struct Ngs2SubmixerVoiceUserFxParam {
Ngs2VoiceParamHeader header;
Ngs2UserFxProcessHandler handler;
std::uintptr_t user_data0;
std::uintptr_t user_data1;
std::uintptr_t user_data2;
};
static_assert(sizeof(Ngs2SubmixerVoiceUserFxParam) == 40);
struct Ngs2CustomSubmixerVoiceSetupParam {
Ngs2VoiceParamHeader header;
std::uint32_t num_input_channels;

View File

@ -1,3 +1,4 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
@ -109,3 +110,15 @@ void Ngs2ProcessUserFx(Ngs2Voice& voice, std::uint32_t grain, std::uint32_t samp
fx.flags = 0;
}
}
void Ngs2ProcessLegacyUserFx(Ngs2Voice& voice, std::uint32_t grain, std::uint32_t sampleRate) {
if (voice.userFxHandler == nullptr) return;
std::array<float*, NGS2_MAX_CHANNELS> channels{};
for (std::uint32_t c = 0; c < voice.channels; ++c) channels[c] = voice.samples.data() + static_cast<std::size_t>(c) * grain;
Ngs2UserFxProcessContext context{channels.data(), voice.userFxData[0], voice.userFxData[1], voice.userFxData[2],
voice.userFxFlags, voice.channels, grain, sampleRate};
const int result = voice.userFxHandler(&context);
if (result != SCE_NGS2_OK) throw std::runtime_error("NGS2: the UserFx process handler failed with " + std::to_string(result));
voice.userFxFlags = 0;
voice.hasSamples = std::any_of(voice.samples.begin(), voice.samples.end(), [](float sample) { return sample != 0.0f; });
}

View File

@ -2,6 +2,7 @@
#define CORE_LIBS_PRX_LIBSCENGS2_SRC_NGS2INTERNAL_HPP
#include <cstddef>
#include <array>
#include <cstdint>
#include <deque>
#include <memory>
@ -114,6 +115,9 @@ struct Ngs2Voice {
float fbwLevel = 1.0f;
float lfeLevel = 1.0f;
std::vector<Ngs2UserFx2> userFx;
Ngs2UserFxProcessHandler userFxHandler = nullptr;
std::array<std::uintptr_t, 3> userFxData{};
std::uint32_t userFxFlags = 0;
std::vector<float> samples;
bool rendering = false;
bool rendered = false;
@ -176,6 +180,7 @@ void Ngs2SetupUserFx(Ngs2Rack& rack, const Ngs2CustomRackOption& option);
void Ngs2CleanupUserFx(Ngs2Rack& rack);
void Ngs2ApplyCustomParam(Ngs2Voice& voice, const Ngs2VoiceParamHeader& param);
void Ngs2ProcessUserFx(Ngs2Voice& voice, std::uint32_t grain, std::uint32_t sampleRate);
void Ngs2ProcessLegacyUserFx(Ngs2Voice& voice, std::uint32_t grain, std::uint32_t sampleRate);
void Ngs2RenderSystem(Ngs2System& system, const Ngs2RenderBufferInfo* bufferInfo, std::uint32_t numBufferInfo);
#endif

View File

@ -188,14 +188,18 @@ static void RenderVoice(Ngs2Voice& voice, const std::vector<Ngs2Voice*>& voices,
if (voice.rendered) return;
if (voice.rendering) throw std::invalid_argument("NGS2: the voice patches form a cycle");
voice.rendering = true;
struct RenderingScope {
bool& active;
~RenderingScope() { active = false; }
} renderingScope{voice.rendering};
voice.samples.assign(static_cast<std::size_t>(grain) * voice.channels, 0.0f);
voice.hasSamples = false;
if (voice.state == Ngs2PlayState::Playing && voice.channels != 0) {
if (voice.rack->rackId == SCE_NGS2_RACK_ID_SAMPLER) RenderSampler(voice, grain, systemRate);
else MixInputs(voice, voices, grain, systemRate);
Ngs2ProcessLegacyUserFx(voice, grain, systemRate);
if (voice.hasSamples) Ngs2ProcessUserFx(voice, grain, systemRate);
}
voice.rendering = false;
voice.rendered = true;
}

View File

@ -25,6 +25,7 @@ void Ngs2Voice::SetEvent(std::uint32_t eventId) {
if (state == Ngs2PlayState::Empty || state == Ngs2PlayState::Stopped) {
state = Ngs2PlayState::Playing;
stateFlags |= SCE_NGS2_VOICE_STATE_FLAG_INUSE;
if (userFxHandler) userFxFlags = 1;
}
break;
case SCE_NGS2_VOICE_EVENT_STOP:
@ -62,6 +63,9 @@ void Ngs2Voice::ResetSetup() {
decodedSamples = 0;
decodedBytes = 0;
waveformEnd = nullptr;
userFxHandler = nullptr;
userFxData = {};
userFxFlags = 0;
}
const std::uint8_t* Ngs2StreamEnd(const Ngs2Voice& voice, const Ngs2Block& block) {
@ -289,6 +293,13 @@ static void ApplyParam(Ngs2Voice& voice, const Ngs2VoiceParamHeader& param) {
switch (rackId) {
case SCE_NGS2_RACK_ID_SAMPLER: ApplySamplerParam(voice, param); return;
case SCE_NGS2_RACK_ID_SUBMIXER:
if (param.id == SCE_NGS2_SUBMIXER_VOICE_PARAM_USER_FX) {
const auto& fx = ParamAs<Ngs2SubmixerVoiceUserFxParam>(param);
voice.userFxHandler = fx.handler;
voice.userFxData = {fx.user_data0, fx.user_data1, fx.user_data2};
voice.userFxFlags = fx.handler ? 1 : 0;
return;
}
if (param.id != SCE_NGS2_SUBMIXER_VOICE_PARAM_SETUP) break;
if (ParamAs<Ngs2SubmixerVoiceSetupParam>(param).flags != 0) throw std::runtime_error("NGS2: submixer setup flags are not implemented");
SetupMixer(voice, ParamAs<Ngs2SubmixerVoiceSetupParam>(param).num_io_channels);

View File

@ -0,0 +1,117 @@
#include "Ngs2Test.hpp"
#include <array>
#include <stdexcept>
struct State {
std::uint32_t calls = 0;
std::uint32_t flags = 0;
std::uint32_t channels = 0;
float gain = 1.0f;
float bias = 0.0f;
int result = 0;
};
static int APS5_VABI Process(Ngs2UserFxProcessContext* context) {
auto& state = *reinterpret_cast<State*>(context->user_data0);
Require(context->user_data1 == 123 && context->user_data2 == 456);
Require(context->num_channels == state.channels && context->num_grain_samples == Grain && context->sample_rate == 48000);
state.calls++;
state.flags = context->flags;
for (std::uint32_t c = 0; c < context->num_channels; ++c)
for (std::uint32_t i = 0; i < context->num_grain_samples; ++i)
context->channel_data[c][i] = context->channel_data[c][i] * state.gain + state.bias * (c + 1);
return state.result;
}
static void Install(uintptr_t voice, State& state) {
Control(voice, SCE_NGS2_SUBMIXER_VOICE_PARAM_USER_FX,
Ngs2SubmixerVoiceUserFxParam{{}, Process, reinterpret_cast<std::uintptr_t>(&state), 123, 456});
}
static std::vector<float> Render(uintptr_t system, std::uint32_t channels) {
std::vector<float> output(Grain * channels, -1.0f);
const Ngs2RenderBufferInfo buffer{output.data(), output.size() * sizeof(float), SCE_NGS2_WAVEFORM_TYPE_PCM_F32L, channels};
Require(sceNgs2SystemRender(system, &buffer, 1) == 0);
return output;
}
static void TestGenerator() {
const auto system = CreateSystem();
const auto master = Mastering(system, 8);
const auto voice = Voice(CreateRack(system, SCE_NGS2_RACK_ID_SUBMIXER));
Control(voice, SCE_NGS2_SUBMIXER_VOICE_PARAM_SETUP, Ngs2SubmixerVoiceSetupParam{{}, 8, 0});
State state{0, 0, 8, 1.0f, 0.125f};
Install(voice, state);
Patch(voice, master);
Event(voice, SCE_NGS2_VOICE_EVENT_PLAY);
auto output = Render(system, 8);
Require(state.calls == 1 && state.flags == 1);
for (std::size_t i = 0; i < output.size(); ++i) Require(output[i] == 0.125f * (i % 8 + 1));
Render(system, 8);
Require(state.calls == 2 && state.flags == 0);
Event(voice, SCE_NGS2_VOICE_EVENT_PAUSE);
for (float sample : Render(system, 8)) Require(sample == 0.0f);
Require(state.calls == 2);
Event(voice, SCE_NGS2_VOICE_EVENT_RESUME);
Render(system, 8);
Require(state.calls == 3 && state.flags == 0);
Event(voice, SCE_NGS2_VOICE_EVENT_STOP_IMM);
for (float sample : Render(system, 8)) Require(sample == 0.0f);
Event(voice, SCE_NGS2_VOICE_EVENT_PLAY);
Render(system, 8);
Require(state.calls == 4 && state.flags == 1);
Control(voice, SCE_NGS2_SUBMIXER_VOICE_PARAM_USER_FX, Ngs2SubmixerVoiceUserFxParam{});
for (float sample : Render(system, 8)) Require(sample == 0.0f);
Require(state.calls == 4);
Install(voice, state);
state.result = -5;
bool rejected = false;
try { Render(system, 8); } catch (const std::runtime_error&) { rejected = true; }
Require(rejected);
state.result = 0;
Render(system, 8);
Require(state.calls == 6 && state.flags == 1);
Control(voice, SCE_NGS2_SUBMIXER_VOICE_PARAM_SETUP, Ngs2SubmixerVoiceSetupParam{{}, 8, 0});
Patch(voice, master);
Event(voice, SCE_NGS2_VOICE_EVENT_PLAY);
for (float sample : Render(system, 8)) Require(sample == 0.0f);
Require(state.calls == 6);
Require(sceNgs2SystemDestroy(system, nullptr) == 0);
}
static void TestEffectAndIndependentVoices() {
const auto system = CreateSystem();
const auto master = Mastering(system, 2);
const auto first = Voice(CreateRack(system, SCE_NGS2_RACK_ID_SUBMIXER));
const auto second = Voice(CreateRack(system, SCE_NGS2_RACK_ID_SUBMIXER));
for (auto voice : {first, second}) {
Control(voice, SCE_NGS2_SUBMIXER_VOICE_PARAM_SETUP, Ngs2SubmixerVoiceSetupParam{{}, 2, 0});
Patch(voice, master);
Event(voice, SCE_NGS2_VOICE_EVENT_PLAY);
}
State firstState{0, 0, 2, 0.5f, 0.0f};
State secondState{0, 0, 2, 1.0f, 0.125f};
Install(first, firstState);
Install(second, secondState);
const auto sampler = Voice(CreateRack(system, SCE_NGS2_RACK_ID_SAMPLER));
Control(sampler, SCE_NGS2_SAMPLER_VOICE_PARAM_SETUP, Ngs2SamplerVoiceSetupParam{{}, {SCE_NGS2_WAVEFORM_TYPE_PCM_I16L, 2, 48000, 0, 0, 0}});
std::array<std::int16_t, Grain * 2> pcm{};
for (std::size_t i = 0; i < pcm.size(); ++i) pcm[i] = i % 2 == 0 ? 16384 : -8192;
const Ngs2WaveformBlock block{0, sizeof(pcm), 0, 0, Grain, 0, 0};
Control(sampler, SCE_NGS2_SAMPLER_VOICE_PARAM_ADD_WAVEFORM_BLOCKS, Ngs2SamplerVoiceWaveformBlocksParam{{}, pcm.data(), 0, 1, &block});
Patch(sampler, first);
Event(sampler, SCE_NGS2_VOICE_EVENT_PLAY);
const auto mixed = Render(system, 2);
for (std::size_t i = 0; i < mixed.size(); ++i) Require(mixed[i] == (i % 2 == 0 ? 0.375f : 0.125f));
Require(firstState.calls == 1 && secondState.calls == 1 && Flags(sampler) == 0);
const auto tail = Render(system, 2);
for (std::size_t i = 0; i < tail.size(); ++i) Require(tail[i] == (i % 2 == 0 ? 0.125f : 0.25f));
Require(firstState.calls == 2 && secondState.calls == 2);
Require(sceNgs2SystemDestroy(system, nullptr) == 0);
}
int main() {
TestGenerator();
TestEffectAndIndependentVoices();
}

View File

@ -41,6 +41,7 @@ Throughout the project, every function at every stage either **does exactly what
- PPSA01317 imports declared without parameters, signatures unknown: the remaining [libSceHmd2](../../core/libs/prx/libSceHmd2/Export.cpp) and [libSceVrTracker2](../../core/libs/prx/libSceVrTracker2/Export.cpp) exports. Names from the aerolib NID list
- [libSceMbus](../../core/libs/prx/libSceMbus/Export.cpp) (`libSceDeviceService`) - signatures taken from PPSA01317, which calls `sceDeviceServiceInitialize(3, 0)`, polls `sceDeviceServiceGetEventState(1)` and queries again when bit 0 or 1 is set, and calls `sceDeviceServiceQueryDeviceInfo_(0x7001, 0, 0, infos, 1, &count, &unknown, 0x70)`, treating a negative result as an error and `count` as the number of entries. The host has no devices: the event state is 0 and a query returns 0 with `count` 0
- [NGS2 ATRAC9 waveform loops](../../core/libs/prx/libSceNgs2.native/src/Atrac9.cpp) accepts one integer forward `smpl` loop. Inclusive RIFF endpoints and encoder-delay adjustment follow [vgmstream riff.c](https://github.com/vgmstream/vgmstream/blob/7dc938fa2f210943b37c7b6511852b516ef432ab/src/meta/riff.c). PPSA03747 supplies a 60-byte sampler chunk with one loop, `samplerData` 24, start 256, inclusive end 299224 and delay 256 for 298969 audible samples. The chunk size and loop count bound the records; `samplerData` is not interpreted. Output positions are assumed to use audible samples and an exclusive end. Blocks split into an optional intro, a repeated range and an optional tail; zero play count means infinite repeats, positive play count includes the first pass. ATRAC9 ranges include a preceding codec frame for decoder history. The output block layout, repeat-count mapping and error codes are unverified on hardware. Multiple, reverse, alternating and fractional loops throw.
- [NGS2 submixer UserFx](../../core/libs/prx/libSceNgs2.native/src/Custom.cpp) (control `0x20000004`) invokes the title's callback after input mixing on every playing grain, including silence. The 40-byte control and 48-byte process context follow [shadPS4](https://github.com/shadps4-emu/shadPS4/blob/945dbc3cc3eee80ac3e053b438502ed936fa6bb2/src/core/libraries/ngs2/ngs2_submixer.h). PPSA03747 installs a peak-meter callback; its code confirms the channel pointers, user data 0, channel count, grain size and sample-rate offsets and returns 0. Other user data words are preserved. Flags are assumed to be 1 after registration/restart and 0 thereafter, following UserFx2's first-process convention; this callback does not inspect them. Null handlers disable the effect, setup clears it, and nonzero callback results throw. These lifecycle and error semantics are unverified on console hardware.
- PPSA01341 imports declared without parameters, signatures unknown: [sceAgcSetSemaphoreMemory](../../core/libs/prx/libSceAgc/Unimplemented.cpp), [sceAgcDriverRegisterMultipleResources](../../core/libs/prx/libSceAgcDriver/Unimplemented.cpp). Names from the shadPS4 aerolib NID list
- [sceAppContentDownloadDataGetAvailableSpaceKb](../../core/libs/prx/libSceAppContent/Export.cpp) (libSceAppContent) - a title whose `param.json` declares `downloadDataSize` 0 gets 0 KB and OK; the console's behaviour for a title without a download data area is unverified (it may return an error instead). KytyPS5 does not special-case it, and shadPS4 stubs 1048576 KB
- [7CxI50-xlCk, pMxXhNozUX8](../../core/libs/prx/libSceNpPartner001/Export.cpp) (libSceNpPartner001) - unknown names and signatures, imported by PPSA23566; declared without parameters