From ed8d1def7ead8ffa397cad09a40d485b9f150ab5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:29:13 +0700 Subject: [PATCH 01/24] test: add deterministic VST3 process fixture --- tests/r0_1/vst3_process_fixture.cpp | 129 ++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/r0_1/vst3_process_fixture.cpp diff --git a/tests/r0_1/vst3_process_fixture.cpp b/tests/r0_1/vst3_process_fixture.cpp new file mode 100644 index 0000000..07a15b0 --- /dev/null +++ b/tests/r0_1/vst3_process_fixture.cpp @@ -0,0 +1,129 @@ +#include "pluginterfaces/vst/vstspeaker.h" +#include "public.sdk/source/main/pluginfactory.h" +#include "public.sdk/source/vst/vstaudioeffect.h" + +#include + +#ifndef SAFEVST3_FIXTURE_CHANNELS +#error "SAFEVST3_FIXTURE_CHANNELS must be defined to 1 or 2" +#endif + +#if SAFEVST3_FIXTURE_CHANNELS != 1 && SAFEVST3_FIXTURE_CHANNELS != 2 +#error "SAFEVST3_FIXTURE_CHANNELS must be 1 or 2" +#endif + +namespace safevst3::test_fixture { + +using namespace Steinberg; +using namespace Steinberg::Vst; + +#if SAFEVST3_FIXTURE_CHANNELS == 1 +static const FUID kProcessorUid(0x3B1069C1, 0x5C474C0E, 0xAA2E925E, 0x6D6B4F01); +constexpr auto kPluginName = "SafeVST3 R0-1 Mono Fixture"; +constexpr SpeakerArrangement kArrangement = SpeakerArr::kMono; +#else +static const FUID kProcessorUid(0x51C2A2D7, 0xE2E047FA, 0xBF0C133E, 0x71A0B202); +constexpr auto kPluginName = "SafeVST3 R0-1 Stereo Fixture"; +constexpr SpeakerArrangement kArrangement = SpeakerArr::kStereo; +#endif + +class ProcessFixture final : public AudioEffect { +public: + static FUnknown* create_instance(void*) { + return static_cast(new ProcessFixture()); + } + + tresult PLUGIN_API initialize(FUnknown* context) override { + const tresult result = AudioEffect::initialize(context); + if (result != kResultOk) + return result; + addAudioInput(STR16("Input"), kArrangement); + addAudioOutput(STR16("Output"), kArrangement); + return kResultOk; + } + + tresult PLUGIN_API setBusArrangements(SpeakerArrangement* inputs, + int32 num_inputs, + SpeakerArrangement* outputs, + int32 num_outputs) override { + if (!inputs || !outputs || num_inputs != 1 || num_outputs != 1) + return kResultFalse; + if (inputs[0] != kArrangement || outputs[0] != kArrangement) + return kResultFalse; + return kResultTrue; + } + + tresult PLUGIN_API setProcessing(TBool) override { + return kResultTrue; + } + + tresult PLUGIN_API canProcessSampleSize(int32 symbolic_sample_size) override { + return symbolic_sample_size == kSample32 ? kResultTrue : kResultFalse; + } + + tresult PLUGIN_API process(ProcessData& data) override { + if (data.numSamples < 0) + return kInvalidArgument; + if (data.numSamples == 0) + return kResultOk; + if (data.numInputs != 1 || data.numOutputs != 1 || !data.inputs || !data.outputs) + return kResultFalse; + + constexpr int32 kChannels = SAFEVST3_FIXTURE_CHANNELS; + if (data.inputs[0].numChannels != kChannels || + data.outputs[0].numChannels != kChannels || + !data.inputs[0].channelBuffers32 || !data.outputs[0].channelBuffers32) + return kResultFalse; + + auto** input = data.inputs[0].channelBuffers32; + auto** output = data.outputs[0].channelBuffers32; + for (int32 channel = 0; channel < kChannels; ++channel) { + if (!input[channel] || !output[channel]) + return kResultFalse; + } + + // A sentinel forces a real IAudioProcessor::process failure. The engine + // characterization uses this to lock the current rule that a failed + // block returns false and does not advance projectTimeSamples. + if (input[0][0] <= -900.0f) + return kResultFalse; + + if (!data.processContext) + return kResultFalse; + const float block_position = + static_cast(data.processContext->projectTimeSamples); + + // Channel-specific gains make stereo->mono and mono->stereo adaptation + // observable without parameters or controller state. Mono uses x2; + // stereo uses x2 on L and x4 on R. + for (int32 channel = 0; channel < kChannels; ++channel) { + const float gain = channel == 0 ? 2.0f : 4.0f; + for (int32 frame = 0; frame < data.numSamples; ++frame) + output[channel][frame] = input[channel][frame] * gain + block_position; + } + data.outputs[0].silenceFlags = 0; + return kResultOk; + } +}; + +} // namespace safevst3::test_fixture + +using namespace Steinberg; +using namespace Steinberg::Vst; +using safevst3::test_fixture::ProcessFixture; +using safevst3::test_fixture::kPluginName; +using safevst3::test_fixture::kProcessorUid; + +BEGIN_FACTORY_DEF("OBS Safe VST3 Tests", "https://github.com/masarray/obs-vst3", "") + +DEF_CLASS2(INLINE_UID_FROM_FUID(kProcessorUid), + PClassInfo::kManyInstances, + kVstAudioEffectClass, + kPluginName, + 0, + "Fx", + "1.0.0", + kVstVersionString, + ProcessFixture::create_instance) + +END_FACTORY From 158d3790dbd548633fde41d36a314dae36e4264d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:29:38 +0700 Subject: [PATCH 02/24] test: characterize current VST3 engine process seam --- .../vst3_engine_process_characterization.cpp | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/r0_1/vst3_engine_process_characterization.cpp diff --git a/tests/r0_1/vst3_engine_process_characterization.cpp b/tests/r0_1/vst3_engine_process_characterization.cpp new file mode 100644 index 0000000..7237098 --- /dev/null +++ b/tests/r0_1/vst3_engine_process_characterization.cpp @@ -0,0 +1,214 @@ +#include "host/vst3_engine.hpp" + +#include +#include +#include +#include + +#ifdef _WIN32 + +namespace { + +using safevst3::AudioSlot; +using safevst3::Vst3Engine; + +bool close_enough(float actual, float expected) { + return std::fabs(actual - expected) <= 1.0e-6f; +} + +bool expect(bool condition, const char* message) { + if (!condition) + std::cerr << "FAIL: " << message << '\n'; + return condition; +} + +bool expect_sample(float actual, float expected, const char* message) { + if (close_enough(actual, expected)) + return true; + std::cerr << "FAIL: " << message << " expected=" << expected + << " actual=" << actual << '\n'; + return false; +} + +bool open_engine(Vst3Engine& engine, + const char* module_path, + std::uint32_t channels) { + std::string error; + if (engine.open(module_path, "", 48000, channels, nullptr, error)) + return true; + std::cerr << "FAIL: open(" << module_path << ", channels=" << channels + << "): " << error << '\n'; + return false; +} + +bool characterize_invalid_blocks(const char* mono_path) { + bool ok = true; + + AudioSlot unopened_slot{}; + unopened_slot.frames = 1; + unopened_slot.channels = 1; + Vst3Engine unopened; + ok &= expect(!unopened.process(unopened_slot), + "unopened engine must reject processing"); + + Vst3Engine engine; + if (!open_engine(engine, mono_path, 1)) + return false; + + AudioSlot slot{}; + slot.channels = 1; + slot.frames = 0; + ok &= expect(!engine.process(slot), "zero-frame block must be rejected"); + + slot.frames = safevst3::kMaxFrames + 1; + ok &= expect(!engine.process(slot), "oversized block must be rejected"); + + slot.frames = 1; + slot.channels = 2; + ok &= expect(!engine.process(slot), "channel-count mismatch must be rejected"); + return ok; +} + +bool characterize_direct_mono_and_position(const char* mono_path) { + Vst3Engine engine; + if (!open_engine(engine, mono_path, 1)) + return false; + + bool ok = true; + AudioSlot first{}; + first.channels = 1; + first.frames = 3; + first.input[0][0] = 1.0f; + first.input[0][1] = 2.0f; + first.input[0][2] = 3.0f; + ok &= expect(engine.process(first), "first mono block must process"); + ok &= expect_sample(first.output[0][0], 2.0f, "mono frame 0"); + ok &= expect_sample(first.output[0][1], 4.0f, "mono frame 1"); + ok &= expect_sample(first.output[0][2], 6.0f, "mono frame 2"); + + AudioSlot second{}; + second.channels = 1; + second.frames = 2; + second.input[0][0] = 0.5f; + second.input[0][1] = 1.5f; + ok &= expect(engine.process(second), "second mono block must process"); + // projectTimeSamples starts at the previous successful block length (3). + ok &= expect_sample(second.output[0][0], 4.0f, + "second block must observe projectTimeSamples=3"); + ok &= expect_sample(second.output[0][1], 6.0f, + "second block frame 1 must preserve block position"); + + AudioSlot failed{}; + failed.channels = 1; + failed.frames = 2; + failed.input[0][0] = -1000.0f; + failed.input[0][1] = 0.0f; + ok &= expect(!engine.process(failed), + "fixture process error must propagate as false"); + + AudioSlot after_failure{}; + after_failure.channels = 1; + after_failure.frames = 1; + after_failure.input[0][0] = 1.0f; + ok &= expect(engine.process(after_failure), + "processing must remain callable after process error"); + // Only the 3-frame and 2-frame successful blocks advanced position. + ok &= expect_sample(after_failure.output[0][0], 7.0f, + "failed block must not advance projectTimeSamples"); + return ok; +} + +bool characterize_stereo_host_to_mono_plugin(const char* mono_path) { + Vst3Engine engine; + if (!open_engine(engine, mono_path, 2)) + return false; + + bool ok = true; + AudioSlot slot{}; + slot.channels = 2; + slot.frames = 2; + slot.input[0][0] = 1.0f; + slot.input[0][1] = 3.0f; + slot.input[1][0] = 3.0f; + slot.input[1][1] = 5.0f; + + ok &= expect(engine.process(slot), + "stereo host -> fixed-mono fixture must process"); + // Engine averages host stereo to [2,4], fixture applies x2, then engine + // duplicates the mono result back to both host output channels. + ok &= expect_sample(slot.output[0][0], 4.0f, "stereo->mono L frame 0"); + ok &= expect_sample(slot.output[1][0], 4.0f, "stereo->mono R frame 0"); + ok &= expect_sample(slot.output[0][1], 8.0f, "stereo->mono L frame 1"); + ok &= expect_sample(slot.output[1][1], 8.0f, "stereo->mono R frame 1"); + return ok; +} + +bool characterize_mono_host_to_stereo_plugin(const char* stereo_path) { + Vst3Engine engine; + if (!open_engine(engine, stereo_path, 1)) + return false; + + bool ok = true; + AudioSlot slot{}; + slot.channels = 1; + slot.frames = 2; + slot.input[0][0] = 2.0f; + slot.input[0][1] = 4.0f; + + ok &= expect(engine.process(slot), + "mono host -> fixed-stereo fixture must process"); + // Engine duplicates mono input. Fixture produces x2 on L and x4 on R; + // engine averages those stereo outputs back to mono => x3. + ok &= expect_sample(slot.output[0][0], 6.0f, "mono->stereo frame 0"); + ok &= expect_sample(slot.output[0][1], 12.0f, "mono->stereo frame 1"); + return ok; +} + +bool characterize_direct_stereo(const char* stereo_path) { + Vst3Engine engine; + if (!open_engine(engine, stereo_path, 2)) + return false; + + bool ok = true; + AudioSlot slot{}; + slot.channels = 2; + slot.frames = 2; + slot.input[0][0] = 1.0f; + slot.input[0][1] = 2.0f; + slot.input[1][0] = 3.0f; + slot.input[1][1] = 4.0f; + + ok &= expect(engine.process(slot), "direct stereo block must process"); + ok &= expect_sample(slot.output[0][0], 2.0f, "direct stereo L frame 0"); + ok &= expect_sample(slot.output[0][1], 4.0f, "direct stereo L frame 1"); + ok &= expect_sample(slot.output[1][0], 12.0f, "direct stereo R frame 0"); + ok &= expect_sample(slot.output[1][1], 16.0f, "direct stereo R frame 1"); + return ok; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 3) { + std::cerr << "usage: r0-1-vst3-engine-process-test \n"; + return 2; + } + + bool ok = true; + ok &= characterize_invalid_blocks(argv[1]); + ok &= characterize_direct_mono_and_position(argv[1]); + ok &= characterize_stereo_host_to_mono_plugin(argv[1]); + ok &= characterize_mono_host_to_stereo_plugin(argv[2]); + ok &= characterize_direct_stereo(argv[2]); + + if (!ok) + return 1; + std::cout << "R0-1 current AudioSlot engine seam characterized successfully\n"; + return 0; +} + +#else + +int main() { return 0; } + +#endif From 5e4da18d3912878c42442419af316c3c1ef41a42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:29:50 +0700 Subject: [PATCH 03/24] test: add standalone R0-1 characterization build --- tests/r0_1/CMakeLists.txt | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/r0_1/CMakeLists.txt diff --git a/tests/r0_1/CMakeLists.txt b/tests/r0_1/CMakeLists.txt new file mode 100644 index 0000000..19ef856 --- /dev/null +++ b/tests/r0_1/CMakeLists.txt @@ -0,0 +1,69 @@ +cmake_minimum_required(VERSION 3.25) +project(safevst3-r0-1-characterization LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT WIN32) + message(FATAL_ERROR "R0-1 real-VST3 characterization is Windows-only") +endif() + +set(SAFEVST3_BUILD_OBS_PLUGIN OFF CACHE BOOL "" FORCE) +set(SAFEVST3_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(SAFEVST3_STATIC_MSVC_RUNTIME ON CACHE BOOL "" FORCE) + +get_filename_component(SAFEVST3_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +add_subdirectory("${SAFEVST3_ROOT}" safevst3-root) + +set(_fixture_source "${CMAKE_CURRENT_LIST_DIR}/vst3_process_fixture.cpp") + +smtg_add_vst3plugin(r0-1-fixture-mono "${_fixture_source}") +target_link_libraries(r0-1-fixture-mono PRIVATE sdk) +target_compile_definitions(r0-1-fixture-mono PRIVATE + SAFEVST3_FIXTURE_CHANNELS=1 + WIN32_LEAN_AND_MEAN + NOMINMAX +) + +smtg_add_vst3plugin(r0-1-fixture-stereo "${_fixture_source}") +target_link_libraries(r0-1-fixture-stereo PRIVATE sdk) +target_compile_definitions(r0-1-fixture-stereo PRIVATE + SAFEVST3_FIXTURE_CHANNELS=2 + WIN32_LEAN_AND_MEAN + NOMINMAX +) + +add_executable(r0-1-vst3-engine-process-test + "${CMAKE_CURRENT_LIST_DIR}/vst3_engine_process_characterization.cpp" + "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp" +) +target_include_directories(r0-1-vst3-engine-process-test PRIVATE + "${SAFEVST3_ROOT}/src" +) +target_link_libraries(r0-1-vst3-engine-process-test PRIVATE + safevst3_vst3_hosting_runtime + safevst3_lifecycle_policy + safevst3_state_restore_policy + safevst3_latency_restart_transaction + safevst3_parameter_refresh_transaction + safevst3_io_restart_transaction + safevst3_reload_component_transaction + safevst3_process_context_policy +) +target_compile_definitions(r0-1-vst3-engine-process-test PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX +) +add_dependencies(r0-1-vst3-engine-process-test + r0-1-fixture-mono + r0-1-fixture-stereo +) + +enable_testing() +add_test( + NAME r0-1-vst3-engine-process-characterization + COMMAND $ + $ + $ +) From 0194ed9ca09de9d17281d8362cf4c83c8c993bef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:29:58 +0700 Subject: [PATCH 04/24] ci: prove R0-1 real-VST3 process seam --- .github/workflows/r0-1-process-seam.yml | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/r0-1-process-seam.yml diff --git a/.github/workflows/r0-1-process-seam.yml b/.github/workflows/r0-1-process-seam.yml new file mode 100644 index 0000000..42d10b6 --- /dev/null +++ b/.github/workflows/r0-1-process-seam.yml @@ -0,0 +1,32 @@ +name: R0-1 Process Seam Characterization + +on: + pull_request: + paths: + - '.github/workflows/r0-1-process-seam.yml' + - 'tests/r0_1/**' + - 'src/host/vst3_engine.cpp' + - 'src/host/vst3_engine.hpp' + - 'src/common/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: r0-1-process-seam-${{ github.ref }} + cancel-in-progress: true + +jobs: + real-vst3-engine-process: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Configure deterministic real-VST3 characterization + run: cmake -S tests/r0_1 -B build/r0-1 -A x64 + - name: Build fixture and engine characterization + run: cmake --build build/r0-1 --config Release --target r0-1-vst3-engine-process-test --parallel + - name: Run unchanged Single AudioSlot characterization + run: ctest --test-dir build/r0-1 -C Release --output-on-failure From 2b5204237f20f2dc26143fef37fce37f29af8688 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:30:27 +0700 Subject: [PATCH 05/24] docs: explain R0-1 characterization gate --- tests/r0_1/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/r0_1/README.md diff --git a/tests/r0_1/README.md b/tests/r0_1/README.md new file mode 100644 index 0000000..8090e4a --- /dev/null +++ b/tests/r0_1/README.md @@ -0,0 +1,12 @@ +# R0-1 deterministic process characterization + +This directory is a test-only pre-mutation gate for Rack R0-1. + +It builds two minimal VST3 modules with the pinned Steinberg SDK already used by the repository: + +- a fixed-mono effect; +- a fixed-stereo effect. + +`vst3_engine_process_characterization.cpp` opens those modules through the production `Vst3Engine` and exercises the unchanged Single `process(AudioSlot&)` seam. The fixture encodes `projectTimeSamples` into deterministic audio so the test locks current block-position behavior in addition to direct mono/stereo processing, mono/stereo adaptation, validation failures, and VST3 process-error propagation. + +This harness is non-shipping and must remain independent of the Single IPC protocol and Rack production runtime. From 19dea6bc8c7461b50945077a21b6214ce07add22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:30:34 +0700 Subject: [PATCH 06/24] test: mark R0-1 harness scope --- tests/r0_1/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/r0_1/.gitkeep diff --git a/tests/r0_1/.gitkeep b/tests/r0_1/.gitkeep new file mode 100644 index 0000000..e69de29 From 3fb50499319934de60e6b9921285b09a8fa21246 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:30:39 +0700 Subject: [PATCH 07/24] test: document non-shipping fixture --- tests/r0_1/NOTICE.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/r0_1/NOTICE.txt diff --git a/tests/r0_1/NOTICE.txt b/tests/r0_1/NOTICE.txt new file mode 100644 index 0000000..31a568c --- /dev/null +++ b/tests/r0_1/NOTICE.txt @@ -0,0 +1 @@ +R0-1 deterministic test fixture only; not part of any shipping package. From 1fc9d94bbad86fbb76e35d07a91a24081e09564e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:30:47 +0700 Subject: [PATCH 08/24] docs: pin R0-1 pre-mutation scope --- tests/r0_1/SCOPE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/r0_1/SCOPE.md diff --git a/tests/r0_1/SCOPE.md b/tests/r0_1/SCOPE.md new file mode 100644 index 0000000..5db6c54 --- /dev/null +++ b/tests/r0_1/SCOPE.md @@ -0,0 +1 @@ +Pre-mutation characterization only. Production `Vst3Engine::process(AudioSlot&)` remains unchanged until this harness passes. From f48501f9bc4d33e5870007353266a6cc1a572174 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:32:43 +0700 Subject: [PATCH 09/24] test: define fixture project version --- tests/r0_1/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/r0_1/CMakeLists.txt b/tests/r0_1/CMakeLists.txt index 19ef856..a9ea654 100644 --- a/tests/r0_1/CMakeLists.txt +++ b/tests/r0_1/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.25) -project(safevst3-r0-1-characterization LANGUAGES C CXX) +project(safevst3-r0-1-characterization VERSION 1.0.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) From 81b772896f0fa2fa7d9c60e14464eebbf267c125 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:38:11 +0700 Subject: [PATCH 10/24] test: include full VST3 process context --- tests/r0_1/vst3_process_fixture.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/r0_1/vst3_process_fixture.cpp b/tests/r0_1/vst3_process_fixture.cpp index 07a15b0..b018586 100644 --- a/tests/r0_1/vst3_process_fixture.cpp +++ b/tests/r0_1/vst3_process_fixture.cpp @@ -1,3 +1,4 @@ +#include "pluginterfaces/vst/ivstprocesscontext.h" #include "pluginterfaces/vst/vstspeaker.h" #include "public.sdk/source/main/pluginfactory.h" #include "public.sdk/source/vst/vstaudioeffect.h" From 6c1fc9ed7ce17ecb65a71886c3f148768f3b25a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:42:20 +0700 Subject: [PATCH 11/24] test: align R0-1 harness MSVC runtime --- tests/r0_1/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/r0_1/CMakeLists.txt b/tests/r0_1/CMakeLists.txt index a9ea654..1e6ff2e 100644 --- a/tests/r0_1/CMakeLists.txt +++ b/tests/r0_1/CMakeLists.txt @@ -9,6 +9,10 @@ if(NOT WIN32) message(FATAL_ERROR "R0-1 real-VST3 characterization is Windows-only") endif() +# The repository deliberately builds its Steinberg hosting/runtime targets with +# the static MSVC runtime. Keep the standalone fixture/test targets on the same +# runtime so the characterization is about VST3 behavior, not CRT mixing. +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") set(SAFEVST3_BUILD_OBS_PLUGIN OFF CACHE BOOL "" FORCE) set(SAFEVST3_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(SAFEVST3_STATIC_MSVC_RUNTIME ON CACHE BOOL "" FORCE) From 5ea7c01b01c9df6c04ffc4428eaeb7e27ea993ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:42:34 +0700 Subject: [PATCH 12/24] test: remove redundant R0-1 placeholder --- tests/r0_1/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/r0_1/.gitkeep diff --git a/tests/r0_1/.gitkeep b/tests/r0_1/.gitkeep deleted file mode 100644 index e69de29..0000000 From b513f9aad06865d82826be0ea477c64734469bbe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:42:46 +0700 Subject: [PATCH 13/24] test: remove redundant R0-1 notice --- tests/r0_1/NOTICE.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/r0_1/NOTICE.txt diff --git a/tests/r0_1/NOTICE.txt b/tests/r0_1/NOTICE.txt deleted file mode 100644 index 31a568c..0000000 --- a/tests/r0_1/NOTICE.txt +++ /dev/null @@ -1 +0,0 @@ -R0-1 deterministic test fixture only; not part of any shipping package. From 7b3fe313aaa7ca4f4c184e867f317a28755d7989 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 05:42:54 +0700 Subject: [PATCH 14/24] test: remove redundant R0-1 scope note --- tests/r0_1/SCOPE.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/r0_1/SCOPE.md diff --git a/tests/r0_1/SCOPE.md b/tests/r0_1/SCOPE.md deleted file mode 100644 index 5db6c54..0000000 --- a/tests/r0_1/SCOPE.md +++ /dev/null @@ -1 +0,0 @@ -Pre-mutation characterization only. Production `Vst3Engine::process(AudioSlot&)` remains unchanged until this harness passes. From 819e394949184ed64d86901b0cf3e13a3306c6ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:15:41 +0700 Subject: [PATCH 15/24] test: disable Steinberg post-build tools for R0-1 fixture --- tests/r0_1/CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/r0_1/CMakeLists.txt b/tests/r0_1/CMakeLists.txt index 1e6ff2e..0d031a9 100644 --- a/tests/r0_1/CMakeLists.txt +++ b/tests/r0_1/CMakeLists.txt @@ -17,6 +17,15 @@ set(SAFEVST3_BUILD_OBS_PLUGIN OFF CACHE BOOL "" FORCE) set(SAFEVST3_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(SAFEVST3_STATIC_MSVC_RUNTIME ON CACHE BOOL "" FORCE) +# These are Steinberg-supported build options. The R0-1 fixture exists only to +# exercise the production engine seam inside CI, so do not run the SDK's own +# post-build validator/module-info generation or create a per-user VST3 link. +# Production builds keep the repository defaults; these settings are local to +# this standalone test project before it includes the repository root. +set(SMTG_RUN_VST_VALIDATOR OFF CACHE BOOL "" FORCE) +set(SMTG_CREATE_MODULE_INFO OFF CACHE BOOL "" FORCE) +set(SMTG_CREATE_PLUGIN_LINK OFF CACHE BOOL "" FORCE) + get_filename_component(SAFEVST3_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) add_subdirectory("${SAFEVST3_ROOT}" safevst3-root) From ba732971ba5b0e0c68598aff46b56bf020a95fd2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:18:13 +0700 Subject: [PATCH 16/24] feat: add protocol-neutral process block view --- src/host/process_block_view.hpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/host/process_block_view.hpp diff --git a/src/host/process_block_view.hpp b/src/host/process_block_view.hpp new file mode 100644 index 0000000..021435f --- /dev/null +++ b/src/host/process_block_view.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace safevst3 { + +// Non-owning protocol-neutral audio view. Buffer ownership and lifetime remain +// with the caller for the duration of process(); no transport layout is +// embedded here. +struct ProcessBlockView { + float* const* input = nullptr; + float* const* output = nullptr; + std::uint32_t channels = 0; + std::uint32_t frames = 0; + std::uint64_t sequence = 0; +}; + +} // namespace safevst3 From c2501f79978f87ae8f6cae42607b21fbc0f8b65e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:18:41 +0700 Subject: [PATCH 17/24] test: specify protocol-neutral process view behavior --- .../vst3_engine_process_characterization.cpp | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/r0_1/vst3_engine_process_characterization.cpp b/tests/r0_1/vst3_engine_process_characterization.cpp index 7237098..4731be4 100644 --- a/tests/r0_1/vst3_engine_process_characterization.cpp +++ b/tests/r0_1/vst3_engine_process_characterization.cpp @@ -1,3 +1,4 @@ +#include "host/process_block_view.hpp" #include "host/vst3_engine.hpp" #include @@ -10,6 +11,7 @@ namespace { using safevst3::AudioSlot; +using safevst3::ProcessBlockView; using safevst3::Vst3Engine; bool close_enough(float actual, float expected) { @@ -186,6 +188,54 @@ bool characterize_direct_stereo(const char* stereo_path) { return ok; } +bool characterize_protocol_neutral_view_matches_single(const char* mono_path) { + Vst3Engine single_engine; + Vst3Engine neutral_engine; + if (!open_engine(single_engine, mono_path, 1) || + !open_engine(neutral_engine, mono_path, 1)) + return false; + + bool ok = true; + AudioSlot slot{}; + slot.sequence = 77; + slot.channels = 1; + slot.frames = 3; + slot.input[0][0] = 0.25f; + slot.input[0][1] = 1.25f; + slot.input[0][2] = 2.25f; + ok &= expect(single_engine.process(slot), "Single adapter comparison block must process"); + + float neutral_input[3] = {0.25f, 1.25f, 2.25f}; + float neutral_output[3] = {}; + float* neutral_inputs[1] = {neutral_input}; + float* neutral_outputs[1] = {neutral_output}; + ProcessBlockView block{ + neutral_inputs, + neutral_outputs, + 1, + 3, + 77, + }; + + ok &= expect(neutral_engine.process(block), + "protocol-neutral ProcessBlockView must process"); + for (std::uint32_t frame = 0; frame < block.frames; ++frame) { + ok &= expect_sample(neutral_output[frame], slot.output[0][frame], + "ProcessBlockView must match Single AudioSlot adapter"); + } + + ProcessBlockView invalid{ + nullptr, + neutral_outputs, + 1, + 1, + 78, + }; + ok &= expect(!neutral_engine.process(invalid), + "protocol-neutral view with null input table must be rejected"); + return ok; +} + } // namespace int main(int argc, char** argv) { @@ -200,10 +250,11 @@ int main(int argc, char** argv) { ok &= characterize_stereo_host_to_mono_plugin(argv[1]); ok &= characterize_mono_host_to_stereo_plugin(argv[2]); ok &= characterize_direct_stereo(argv[2]); + ok &= characterize_protocol_neutral_view_matches_single(argv[1]); if (!ok) return 1; - std::cout << "R0-1 current AudioSlot engine seam characterized successfully\n"; + std::cout << "R0-1 AudioSlot and ProcessBlockView engine seams characterized successfully\n"; return 0; } From da3de86f40a65665d4b0a72809e549d2120aecde Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:19:00 +0700 Subject: [PATCH 18/24] feat: add protocol-neutral VST3 process entry --- src/host/vst3_engine.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/host/vst3_engine.hpp b/src/host/vst3_engine.hpp index 8f33cdb..0475f24 100644 --- a/src/host/vst3_engine.hpp +++ b/src/host/vst3_engine.hpp @@ -8,6 +8,7 @@ #include "common/process_context_policy.hpp" #include "common/startup_error.hpp" #include "common/state_snapshot.hpp" +#include "host/process_block_view.hpp" #include "host/vst3_processing_compat.hpp" #include "pluginterfaces/vst/ivstaudioprocessor.h" @@ -73,6 +74,7 @@ class Vst3Engine final : public LatencyRestartTarget, public IoRestartLifecycleT return opened; } void close() noexcept; + bool process(const ProcessBlockView& block) noexcept; bool process(AudioSlot& slot) noexcept; bool capture_state(PluginStateSnapshot& snapshot, std::string& error); From d71575f723152474cfdaefbd65c31c7d91844e2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:21:26 +0700 Subject: [PATCH 19/24] feat: migrate VST3 processing behind ProcessBlockView --- src/host/vst3_engine.cpp | 42 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/host/vst3_engine.cpp b/src/host/vst3_engine.cpp index 05f5b1c..9e92d2a 100644 --- a/src/host/vst3_engine.cpp +++ b/src/host/vst3_engine.cpp @@ -1130,25 +1130,45 @@ bool Vst3Engine::flush_parameter_changes() noexcept bool Vst3Engine::process(AudioSlot& slot) noexcept { - if (!processor_ || slot.frames == 0 || slot.frames > kMaxFrames || - slot.channels != channels_ || + float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; + float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; + const ProcessBlockView block{ + input, + output, + slot.channels, + slot.frames, + slot.sequence, + }; + return process(block); +} + +bool Vst3Engine::process(const ProcessBlockView& block) noexcept +{ + if (!processor_ || !block.input || !block.output || + block.frames == 0 || block.frames > kMaxFrames || + block.channels != channels_ || (plugin_input_channels_ != 1 && plugin_input_channels_ != 2) || (plugin_output_channels_ != 1 && plugin_output_channels_ != 2)) return false; + for (std::uint32_t ch = 0; ch < block.channels; ++ch) { + if (!block.input[ch] || !block.output[ch]) + return false; + } + Sample32* in[kMaxChannels]{}; Sample32* out[kMaxChannels]{}; if (plugin_input_channels_ == channels_) { for (std::uint32_t ch = 0; ch < channels_; ++ch) - in[ch] = slot.input[ch]; + in[ch] = block.input[ch]; } else if (channels_ == 2 && plugin_input_channels_ == 1) { average_stereo_to_mono( - slot.input[0], slot.input[1], input_adapter_[0].data(), slot.frames); + block.input[0], block.input[1], input_adapter_[0].data(), block.frames); in[0] = input_adapter_[0].data(); } else if (channels_ == 1 && plugin_input_channels_ == 2) { duplicate_mono_to_stereo( - slot.input[0], input_adapter_[0].data(), input_adapter_[1].data(), slot.frames); + block.input[0], input_adapter_[0].data(), input_adapter_[1].data(), block.frames); in[0] = input_adapter_[0].data(); in[1] = input_adapter_[1].data(); } else { @@ -1158,7 +1178,7 @@ bool Vst3Engine::process(AudioSlot& slot) noexcept const bool output_direct = plugin_output_channels_ == channels_; if (output_direct) { for (std::uint32_t ch = 0; ch < channels_; ++ch) - out[ch] = slot.output[ch]; + out[ch] = block.output[ch]; } else { for (std::uint32_t ch = 0; ch < plugin_output_channels_; ++ch) out[ch] = output_adapter_[ch].data(); @@ -1170,7 +1190,7 @@ bool Vst3Engine::process(AudioSlot& slot) noexcept kOutput, main_output_bus_, out, static_cast(plugin_output_channels_))) return false; - process_data_.numSamples = static_cast(slot.frames); + process_data_.numSamples = static_cast(block.frames); process_data_.inputEvents = nullptr; process_data_.outputEvents = nullptr; process_data_.outputParameterChanges = &output_parameter_changes_; @@ -1188,16 +1208,16 @@ bool Vst3Engine::process(AudioSlot& slot) noexcept capture_output_parameter_changes(); if (result != kResultOk) return false; - sample_position_ += slot.frames; + sample_position_ += block.frames; if (!output_direct) { if (channels_ == 2 && plugin_output_channels_ == 1) { duplicate_mono_to_stereo( - output_adapter_[0].data(), slot.output[0], slot.output[1], slot.frames); + output_adapter_[0].data(), block.output[0], block.output[1], block.frames); } else if (channels_ == 1 && plugin_output_channels_ == 2) { average_stereo_to_mono( output_adapter_[0].data(), output_adapter_[1].data(), - slot.output[0], slot.frames); + block.output[0], block.frames); } else { return false; } @@ -1208,4 +1228,4 @@ bool Vst3Engine::process(AudioSlot& slot) noexcept } // namespace safevst3 -#endif +#endif \ No newline at end of file From 29af83c9084e03709e6098945d1604fc2f8cd691 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:23:37 +0700 Subject: [PATCH 20/24] test: follow neutral process block sample advance --- tests/process_context_source_contract.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/process_context_source_contract.cmake b/tests/process_context_source_contract.cmake index ed64181..ce2d51c 100644 --- a/tests/process_context_source_contract.cmake +++ b/tests/process_context_source_contract.cmake @@ -28,7 +28,7 @@ endif() string(FIND "${SOURCE}" "const auto context_frame = make_process_context_frame(" BLOCK_FRAME_POS) string(FIND "${SOURCE}" "processor_->process(process_data_)" PROCESS_POS) -string(FIND "${SOURCE}" "sample_position_ += slot.frames" ADVANCE_POS) +string(FIND "${SOURCE}" "sample_position_ += block.frames" ADVANCE_POS) if(BLOCK_FRAME_POS LESS 0 OR PROCESS_POS LESS 0 OR ADVANCE_POS LESS 0) message(FATAL_ERROR "Could not find deterministic audio-block process-context markers") endif() From f60e9377fb232ee34d849273c940f214b5e95f73 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:23:45 +0700 Subject: [PATCH 21/24] ci: qualify ProcessBlockView seam explicitly --- .github/workflows/r0-1-process-seam.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/r0-1-process-seam.yml b/.github/workflows/r0-1-process-seam.yml index 42d10b6..6d23f0f 100644 --- a/.github/workflows/r0-1-process-seam.yml +++ b/.github/workflows/r0-1-process-seam.yml @@ -5,6 +5,7 @@ on: paths: - '.github/workflows/r0-1-process-seam.yml' - 'tests/r0_1/**' + - 'src/host/process_block_view.hpp' - 'src/host/vst3_engine.cpp' - 'src/host/vst3_engine.hpp' - 'src/common/**' @@ -28,5 +29,5 @@ jobs: run: cmake -S tests/r0_1 -B build/r0-1 -A x64 - name: Build fixture and engine characterization run: cmake --build build/r0-1 --config Release --target r0-1-vst3-engine-process-test --parallel - - name: Run unchanged Single AudioSlot characterization + - name: Run AudioSlot and ProcessBlockView characterization run: ctest --test-dir build/r0-1 -C Release --output-on-failure From 0d3f2031cc20667123f83fc3b23e53539329ed58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:23:58 +0700 Subject: [PATCH 22/24] docs: record two-stage R0-1 characterization --- tests/r0_1/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/r0_1/README.md b/tests/r0_1/README.md index 8090e4a..8966942 100644 --- a/tests/r0_1/README.md +++ b/tests/r0_1/README.md @@ -1,12 +1,16 @@ # R0-1 deterministic process characterization -This directory is a test-only pre-mutation gate for Rack R0-1. +This directory is the deterministic real-VST3 gate for Rack R0-1. It builds two minimal VST3 modules with the pinned Steinberg SDK already used by the repository: - a fixed-mono effect; - a fixed-stereo effect. -`vst3_engine_process_characterization.cpp` opens those modules through the production `Vst3Engine` and exercises the unchanged Single `process(AudioSlot&)` seam. The fixture encodes `projectTimeSamples` into deterministic audio so the test locks current block-position behavior in addition to direct mono/stereo processing, mono/stereo adaptation, validation failures, and VST3 process-error propagation. +The gate was deliberately established in two stages. First, before any production process mutation, `vst3_engine_process_characterization.cpp` opened the fixture modules through the production `Vst3Engine` and exercised the unchanged Single `process(AudioSlot&)` seam. That pre-mutation proof passed on source head `819e394949184ed64d86901b0cf3e13a3306c6ea` in R0-1 Process Seam Characterization run `33135381993`. -This harness is non-shipping and must remain independent of the Single IPC protocol and Rack production runtime. +After that gate passed, R0-1 introduced `ProcessBlockView`. The same harness now keeps all of the original AudioSlot characterization and also drives the protocol-neutral process entry with caller-owned raw buffers, requiring its deterministic output to match an independently opened Single AudioSlot engine. It also verifies invalid neutral buffer tables fail cleanly. + +The fixture encodes `projectTimeSamples` into deterministic audio, so the test locks current block-position behavior in addition to direct mono/stereo processing, mono/stereo adaptation, validation failures, and VST3 process-error propagation. + +This harness is non-shipping. It does not define or change the Single IPC protocol and it contains no Rack production runtime. From e09a6b609ca629ae9cd144d1d5a8e9c94f603929 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:24:31 +0700 Subject: [PATCH 23/24] ci: include R0-1 host seam in compatibility gate --- .github/workflows/compat-test-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/compat-test-build.yml b/.github/workflows/compat-test-build.yml index 44dc94c..fa264a4 100644 --- a/.github/workflows/compat-test-build.yml +++ b/.github/workflows/compat-test-build.yml @@ -8,6 +8,9 @@ on: - 'src/obs-plugin/obs_compat_floor.hpp' - 'src/obs-plugin/plugin.cpp' - 'src/host/native_editor.cpp' + - 'src/host/process_block_view.hpp' + - 'src/host/vst3_engine.cpp' + - 'src/host/vst3_engine.hpp' - 'src/scanner/main.cpp' - 'installer/windows/obs-safe-vst3.iss' - 'data/locale/en-US.ini' From dc3a52bdb8b6a15026f39c46130b42f0acee5c6a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:59:07 +0700 Subject: [PATCH 24/24] fix: preserve OBS source registration ABI floor --- src/obs-plugin/obs_compat_floor.hpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/obs-plugin/obs_compat_floor.hpp b/src/obs-plugin/obs_compat_floor.hpp index 93b7b83..d2076a9 100644 --- a/src/obs-plugin/obs_compat_floor.hpp +++ b/src/obs-plugin/obs_compat_floor.hpp @@ -1,8 +1,28 @@ #pragma once +#include #include // Minimum libobs API used by this plugin: OBS Studio 29.1. -// clean-test3 rebuild marker: also validates stale-copy installer cleanup. #define SAFEVST3_OBS_MIN_API_VER MAKE_SEMANTIC_VERSION(29, 1, 0) #undef LIBOBS_API_VER #define LIBOBS_API_VER SAFEVST3_OBS_MIN_API_VER + +// obs_register_source() always forwards sizeof(struct obs_source_info) from +// the SDK used to build the module. Newer OBS SDKs append fields to that +// structure, so forwarding the full current size makes otherwise-compatible +// modules fail registration on older libobs runtimes. +// +// Safe VST3 only populates source-info fields through `save`; all fields after +// it are intentionally unused. Register exactly that ABI prefix so old libobs +// zero-fills its own trailing fields while current libobs receives every +// callback this module actually uses. Keep this boundary in sync if a future +// source implementation starts using a field after `save`. +#define SAFEVST3_OBS_SOURCE_INFO_COMPAT_SIZE \ + (offsetof(struct obs_source_info, save) + sizeof(((struct obs_source_info*)0)->save)) + +static_assert(SAFEVST3_OBS_SOURCE_INFO_COMPAT_SIZE <= sizeof(struct obs_source_info), + "OBS source-info compatibility prefix exceeds the build SDK structure"); + +#undef obs_register_source +#define obs_register_source(info) \ + obs_register_source_s((info), SAFEVST3_OBS_SOURCE_INFO_COMPAT_SIZE)