diff --git a/.github/workflows/r0-2-hosted-plugin.yml b/.github/workflows/r0-2-hosted-plugin.yml new file mode 100644 index 0000000..da30aba --- /dev/null +++ b/.github/workflows/r0-2-hosted-plugin.yml @@ -0,0 +1,36 @@ +name: R0-2 HostedPlugin Characterization + +on: + pull_request: + paths: + - '.github/workflows/r0-2-hosted-plugin.yml' + - 'tests/r0_2/**' + - 'src/host/hosted_plugin.cpp' + - 'src/host/hosted_plugin.hpp' + - 'src/host/hosted_plugin_types.hpp' + - 'src/host/process_block_view.hpp' + - 'src/host/vst3_engine.cpp' + - 'src/host/vst3_engine.hpp' + - 'src/common/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: r0-2-hosted-plugin-${{ github.ref }} + cancel-in-progress: true + +jobs: + hosted-plugin-deep-seam: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Configure deterministic HostedPlugin characterization + run: cmake -S tests/r0_2 -B build/r0-2 -A x64 + - name: Build stateful fixture and HostedPlugin characterization + run: cmake --build build/r0-2 --config Release --target r0-2-hosted-plugin-test --parallel + - name: Run lifecycle/state/process/source-contract characterization + run: ctest --test-dir build/r0-2 -C Release --output-on-failure diff --git a/src/host/hosted_plugin.cpp b/src/host/hosted_plugin.cpp new file mode 100644 index 0000000..50c3989 --- /dev/null +++ b/src/host/hosted_plugin.cpp @@ -0,0 +1,156 @@ +#ifdef _WIN32 + +#include "host/hosted_plugin.hpp" +#include "host/vst3_engine.hpp" + +#include + +namespace safevst3 { + +struct HostedPlugin::Impl { + Vst3Engine engine; +}; + +HostedPlugin::HostedPlugin() : impl_(std::make_unique()) {} +HostedPlugin::~HostedPlugin() = default; + +bool HostedPlugin::open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + StartupPhaseSink* startup_phase_sink, + std::string& error) +{ + return impl_->engine.open(path, class_id, sample_rate, channels, + component_handler, startup_phase_sink, error); +} + +bool HostedPlugin::open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + std::string& error) +{ + return impl_->engine.open(path, class_id, sample_rate, channels, + component_handler, error); +} + +void HostedPlugin::close() noexcept +{ + impl_->engine.close(); +} + +bool HostedPlugin::process(const ProcessBlockView& block) noexcept +{ + return impl_->engine.process(block); +} + +bool HostedPlugin::capture_state(PluginStateSnapshot& snapshot, std::string& error) +{ + return impl_->engine.capture_state(snapshot, error); +} + +bool HostedPlugin::restore_state(const PluginStateSnapshot& snapshot, std::string& error) +{ + return impl_->engine.restore_state(snapshot, error); +} + +bool HostedPlugin::refresh_latency_after_restart(std::string& error) +{ + return impl_->engine.refresh_latency_after_restart(error); +} + +bool HostedPlugin::reconfigure_io_after_restart(IoLayout& layout, + std::uint32_t& latency_samples, + std::string& error) +{ + return impl_->engine.reconfigure_io_after_restart(layout, latency_samples, error); +} + +bool HostedPlugin::queue_parameter(std::uint32_t id, double normalized) noexcept +{ + return impl_->engine.queue_parameter(id, normalized); +} + +bool HostedPlugin::queue_parameter_from_controller(std::uint32_t id, + double normalized) noexcept +{ + return impl_->engine.queue_parameter_from_controller(id, normalized); +} + +bool HostedPlugin::set_controller_parameter(std::uint32_t id, double normalized) noexcept +{ + return impl_->engine.set_controller_parameter(id, normalized); +} + +bool HostedPlugin::queue_processor_parameter(std::uint32_t id, double normalized) noexcept +{ + return impl_->engine.queue_processor_parameter(id, normalized); +} + +bool HostedPlugin::flush_parameter_changes() noexcept +{ + return impl_->engine.flush_parameter_changes(); +} + +void HostedPlugin::refresh_parameter_values() noexcept +{ + impl_->engine.refresh_parameter_values(); +} + +bool HostedPlugin::refresh_parameter_metadata(std::string& error) +{ + return impl_->engine.refresh_parameter_metadata(error); +} + +std::size_t HostedPlugin::take_parameter_updates(EngineParameterUpdate* destination, + std::size_t capacity) noexcept +{ + return impl_->engine.take_parameter_updates(destination, capacity); +} + +void HostedPlugin::set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept +{ + impl_->engine.set_component_handler(handler); +} + +Steinberg::Vst::IEditController* HostedPlugin::edit_controller() const noexcept +{ + return impl_->engine.edit_controller(); +} + +const std::string& HostedPlugin::plugin_name() const noexcept +{ + return impl_->engine.plugin_name(); +} + +const std::string& HostedPlugin::loaded_class_id() const noexcept +{ + return impl_->engine.loaded_class_id(); +} + +std::uint32_t HostedPlugin::latency_samples() const noexcept +{ + return impl_->engine.latency_samples(); +} + +std::uint32_t HostedPlugin::process_context_requirements() const noexcept +{ + return impl_->engine.process_context_requirements(); +} + +std::uint32_t HostedPlugin::unsupported_process_context_requirements() const noexcept +{ + return impl_->engine.unsupported_process_context_requirements(); +} + +const std::vector& HostedPlugin::parameters() const noexcept +{ + return impl_->engine.parameters(); +} + +} // namespace safevst3 + +#endif diff --git a/src/host/hosted_plugin.hpp b/src/host/hosted_plugin.hpp new file mode 100644 index 0000000..6165b63 --- /dev/null +++ b/src/host/hosted_plugin.hpp @@ -0,0 +1,92 @@ +#pragma once + +#ifdef _WIN32 + +#include "common/io_restart_transaction.hpp" +#include "common/startup_error.hpp" +#include "common/state_snapshot.hpp" +#include "host/hosted_plugin_types.hpp" +#include "host/process_block_view.hpp" + +#include +#include +#include +#include +#include + +namespace Steinberg::Vst { +class IComponentHandler; +class IEditController; +} // namespace Steinberg::Vst + +namespace safevst3 { + +// Deep helper-side owner for exactly one VST3 audio-effect instance. +// +// This is the protocol-neutral reuse boundary for Rack work. Single/Rack +// transport adapters, OBS recovery orchestration, shared-memory layouts and +// topology remain outside this object. The implementation deliberately wraps +// the already-qualified Vst3Engine during R0-2 so lifecycle/state behavior is +// reused rather than rewritten. +class HostedPlugin final { +public: + HostedPlugin(); + ~HostedPlugin(); + + HostedPlugin(const HostedPlugin&) = delete; + HostedPlugin& operator=(const HostedPlugin&) = delete; + HostedPlugin(HostedPlugin&&) = delete; + HostedPlugin& operator=(HostedPlugin&&) = delete; + + bool open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + StartupPhaseSink* startup_phase_sink, + std::string& error); + bool open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + std::string& error); + void close() noexcept; + + bool process(const ProcessBlockView& block) noexcept; + + bool capture_state(PluginStateSnapshot& snapshot, std::string& error); + bool restore_state(const PluginStateSnapshot& snapshot, std::string& error); + bool refresh_latency_after_restart(std::string& error); + bool reconfigure_io_after_restart(IoLayout& layout, + std::uint32_t& latency_samples, + std::string& error); + + bool queue_parameter(std::uint32_t id, double normalized) noexcept; + bool queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept; + bool set_controller_parameter(std::uint32_t id, double normalized) noexcept; + bool queue_processor_parameter(std::uint32_t id, double normalized) noexcept; + bool flush_parameter_changes() noexcept; + void refresh_parameter_values() noexcept; + bool refresh_parameter_metadata(std::string& error); + std::size_t take_parameter_updates(EngineParameterUpdate* destination, + std::size_t capacity) noexcept; + + void set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept; + Steinberg::Vst::IEditController* edit_controller() const noexcept; + + const std::string& plugin_name() const noexcept; + const std::string& loaded_class_id() const noexcept; + std::uint32_t latency_samples() const noexcept; + std::uint32_t process_context_requirements() const noexcept; + std::uint32_t unsupported_process_context_requirements() const noexcept; + const std::vector& parameters() const noexcept; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace safevst3 + +#endif diff --git a/src/host/hosted_plugin_types.hpp b/src/host/hosted_plugin_types.hpp new file mode 100644 index 0000000..6001e69 --- /dev/null +++ b/src/host/hosted_plugin_types.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace safevst3 { + +// Protocol-neutral metadata owned by the hosted VST3 lifecycle seam. These +// types intentionally contain no Single transport or shared-memory fields. +struct EngineParameter { + std::uint32_t id = 0; + std::int32_t step_count = 0; + std::uint32_t flags = 0; + double default_normalized = 0.0; + double current_normalized = 0.0; + std::string title; + std::string units; +}; + +struct EngineParameterUpdate { + std::uint32_t id = 0; + double normalized = 0.0; +}; + +} // namespace safevst3 diff --git a/src/host/vst3_engine.hpp b/src/host/vst3_engine.hpp index 0475f24..742a150 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/hosted_plugin_types.hpp" #include "host/process_block_view.hpp" #include "host/vst3_processing_compat.hpp" @@ -29,21 +30,6 @@ namespace safevst3 { -struct EngineParameter { - std::uint32_t id = 0; - std::int32_t step_count = 0; - std::uint32_t flags = 0; - double default_normalized = 0.0; - double current_normalized = 0.0; - std::string title; - std::string units; -}; - -struct EngineParameterUpdate { - std::uint32_t id = 0; - double normalized = 0.0; -}; - class Vst3Engine final : public LatencyRestartTarget, public IoRestartLifecycleTarget { public: Vst3Engine() = default; @@ -186,4 +172,4 @@ class Vst3Engine final : public LatencyRestartTarget, public IoRestartLifecycleT } // namespace safevst3 -#endif \ No newline at end of file +#endif diff --git a/tests/r0_2/CMakeLists.txt b/tests/r0_2/CMakeLists.txt new file mode 100644 index 0000000..c7ba099 --- /dev/null +++ b/tests/r0_2/CMakeLists.txt @@ -0,0 +1,67 @@ +cmake_minimum_required(VERSION 3.25) +project(safevst3-r0-2-characterization VERSION 1.0.0 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-2 real-VST3 characterization is Windows-only") +endif() + +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) +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) + +smtg_add_vst3plugin(r0-2-stateful-fixture + "${CMAKE_CURRENT_LIST_DIR}/vst3_stateful_fixture.cpp" +) +target_link_libraries(r0-2-stateful-fixture PRIVATE sdk) +target_compile_definitions(r0-2-stateful-fixture PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX +) + +add_executable(r0-2-hosted-plugin-test + "${CMAKE_CURRENT_LIST_DIR}/hosted_plugin_characterization.cpp" + "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp" + "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp" +) +target_include_directories(r0-2-hosted-plugin-test PRIVATE + "${SAFEVST3_ROOT}/src" +) +target_link_libraries(r0-2-hosted-plugin-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-2-hosted-plugin-test PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX +) +add_dependencies(r0-2-hosted-plugin-test r0-2-stateful-fixture) + +enable_testing() +add_test( + NAME r0-2-hosted-plugin-characterization + COMMAND $ + $ +) +add_test( + NAME r0-2-hosted-plugin-source-contract + COMMAND ${CMAKE_COMMAND} + -DHOSTED_PLUGIN_HEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp + -P ${CMAKE_CURRENT_LIST_DIR}/hosted_plugin_source_contract.cmake +) diff --git a/tests/r0_2/hosted_plugin_characterization.cpp b/tests/r0_2/hosted_plugin_characterization.cpp new file mode 100644 index 0000000..6956bf2 --- /dev/null +++ b/tests/r0_2/hosted_plugin_characterization.cpp @@ -0,0 +1,215 @@ +#include "host/hosted_plugin.hpp" + +#include "pluginterfaces/vst/ivsteditcontroller.h" + +#include +#include +#include +#include + +#ifdef _WIN32 + +namespace { + +using safevst3::HostedPlugin; +using safevst3::IoLayout; +using safevst3::PluginStateSnapshot; +using safevst3::ProcessBlockView; + +constexpr std::uint32_t kGainId = 7001; +constexpr std::uint32_t kExpectedLatency = 37; + +class TestComponentHandler final : public Steinberg::Vst::IComponentHandler { +public: + Steinberg::tresult PLUGIN_API beginEdit(Steinberg::Vst::ParamID) override + { + return Steinberg::kResultOk; + } + + Steinberg::tresult PLUGIN_API performEdit(Steinberg::Vst::ParamID, + Steinberg::Vst::ParamValue) override + { + return Steinberg::kResultOk; + } + + Steinberg::tresult PLUGIN_API endEdit(Steinberg::Vst::ParamID) override + { + return Steinberg::kResultOk; + } + + Steinberg::tresult PLUGIN_API restartComponent(Steinberg::int32) override + { + return Steinberg::kResultOk; + } + + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override + { + if (!obj) + return Steinberg::kInvalidArgument; + *obj = nullptr; + if (Steinberg::FUnknownPrivate::iidEqual(iid, Steinberg::Vst::IComponentHandler::iid) || + Steinberg::FUnknownPrivate::iidEqual(iid, Steinberg::FUnknown::iid)) { + *obj = static_cast(this); + addRef(); + return Steinberg::kResultTrue; + } + return Steinberg::kNoInterface; + } + + Steinberg::uint32 PLUGIN_API addRef() override { return 1000; } + Steinberg::uint32 PLUGIN_API release() override { return 1000; } +}; + +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 (std::fabs(actual - expected) <= 1.0e-6f) + return true; + std::cerr << "FAIL: " << message << " expected=" << expected + << " actual=" << actual << '\n'; + return false; +} + +bool open_plugin(HostedPlugin& plugin, + TestComponentHandler& component_handler, + const char* module_path) +{ + std::string error; + if (plugin.open(module_path, "", 48000, 2, &component_handler, error)) + return true; + std::cerr << "FAIL: HostedPlugin open: " << error << '\n'; + return false; +} + +bool process_stereo(HostedPlugin& plugin, + float left, + float right, + float expected_left, + float expected_right, + std::uint64_t sequence) +{ + float left_input[1] = {left}; + float right_input[1] = {right}; + float left_output[1] = {}; + float right_output[1] = {}; + float* inputs[2] = {left_input, right_input}; + float* outputs[2] = {left_output, right_output}; + const ProcessBlockView block{inputs, outputs, 2, 1, sequence}; + + bool ok = true; + ok &= expect(plugin.process(block), "protocol-neutral HostedPlugin process must succeed"); + ok &= expect_sample(left_output[0], expected_left, "left processed sample"); + ok &= expect_sample(right_output[0], expected_right, "right processed sample"); + return ok; +} + +bool characterize_hosted_plugin(const char* module_path) +{ + TestComponentHandler original_handler; + HostedPlugin original; + if (!open_plugin(original, original_handler, module_path)) + return false; + + bool ok = true; + ok &= expect(original.edit_controller() != nullptr, + "HostedPlugin must own/expose the loaded controller for helper-owned editor access"); + ok &= expect(original.latency_samples() == kExpectedLatency, + "initial latency must come from the hosted processor"); + ok &= expect(!original.parameters().empty(), + "controller parameter catalog must remain available through HostedPlugin"); + + ok &= process_stereo(original, 2.0f, 4.0f, 3.0f, 6.0f, 1); + + // Drive a real processor-side change through the deep parameter seam. The + // fixture records the resulting normalized gain in component state. + ok &= expect(original.queue_parameter(kGainId, 0.75), + "HostedPlugin must accept a known controller/processor parameter"); + ok &= process_stereo(original, 2.0f, 4.0f, 3.5f, 7.0f, 2); + + PluginStateSnapshot snapshot; + std::string error; + ok &= expect(original.capture_state(snapshot, error), + "HostedPlugin component/controller state capture must succeed"); + if (!error.empty()) + std::cerr << "state capture detail: " << error << '\n'; + ok &= expect(!snapshot.component.empty(), "component state blob must be present"); + ok &= expect(!snapshot.controller.empty(), "controller-private state blob must be present"); + + TestComponentHandler restored_handler; + HostedPlugin restored; + if (!open_plugin(restored, restored_handler, module_path)) + return false; + ok &= expect(restored.restore_state(snapshot, error), + "HostedPlugin complete state restore must succeed"); + if (!error.empty()) + std::cerr << "state restore detail: " << error << '\n'; + ok &= process_stereo(restored, 2.0f, 4.0f, 3.5f, 7.0f, 3); + + PluginStateSnapshot round_trip; + ok &= expect(restored.capture_state(round_trip, error), + "restored HostedPlugin state recapture must succeed"); + ok &= expect(round_trip.component == snapshot.component, + "component state must round-trip byte-for-byte"); + ok &= expect(round_trip.controller == snapshot.controller, + "controller-private state must round-trip byte-for-byte"); + + ok &= expect(restored.refresh_latency_after_restart(error), + "HostedPlugin latency restart transaction must remain supported"); + ok &= expect(restored.latency_samples() == kExpectedLatency, + "latency restart must commit the processor latency"); + + IoLayout layout{}; + std::uint32_t latency = 0; + ok &= expect(restored.reconfigure_io_after_restart(layout, latency, error), + "HostedPlugin I/O restart lifecycle must remain supported"); + ok &= expect(layout.input_channels == 2 && layout.output_channels == 2, + "I/O restart must preserve the supported stereo layout"); + ok &= expect(latency == kExpectedLatency, + "I/O restart must return the current processor latency"); + + restored.close(); + ok &= expect(restored.edit_controller() == nullptr, + "controller accessor must be cleared by HostedPlugin close"); + + float input[1] = {1.0f}; + float output[1] = {}; + float* inputs[2] = {input, input}; + float* outputs[2] = {output, output}; + const ProcessBlockView after_close{inputs, outputs, 2, 1, 4}; + ok &= expect(!restored.process(after_close), + "closed HostedPlugin must reject process calls"); + + ok &= expect(open_plugin(restored, restored_handler, module_path), + "HostedPlugin must support a clean reopen after close"); + restored.close(); + original.close(); + return ok; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 2) { + std::cerr << "usage: r0-2-hosted-plugin-test \n"; + return 2; + } + + if (!characterize_hosted_plugin(argv[1])) + return 1; + + std::cout << "R0-2 HostedPlugin lifecycle/state/process seam characterized successfully\n"; + return 0; +} + +#else + +int main() { return 0; } + +#endif diff --git a/tests/r0_2/hosted_plugin_source_contract.cmake b/tests/r0_2/hosted_plugin_source_contract.cmake new file mode 100644 index 0000000..54af2b5 --- /dev/null +++ b/tests/r0_2/hosted_plugin_source_contract.cmake @@ -0,0 +1,32 @@ +if(NOT DEFINED HOSTED_PLUGIN_HEADER) + message(FATAL_ERROR "HOSTED_PLUGIN_HEADER is required") +endif() + +file(READ "${HOSTED_PLUGIN_HEADER}" HEADER) + +foreach(FORBIDDEN IN ITEMS + "common/protocol.hpp" + "AudioSlot" + "SharedAudioRegion" + "RackSlotRuntime" + "RackChainGeneration" +) + string(FIND "${HEADER}" "${FORBIDDEN}" FOUND) + if(NOT FOUND EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam leaked forbidden transport/topology symbol: ${FORBIDDEN}") + endif() +endforeach() + +foreach(REQUIRED IN ITEMS + "class HostedPlugin" + "ProcessBlockView" + "capture_state" + "restore_state" + "latency_samples" + "edit_controller" +) + string(FIND "${HEADER}" "${REQUIRED}" FOUND) + if(FOUND EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam missing required responsibility marker: ${REQUIRED}") + endif() +endforeach() diff --git a/tests/r0_2/vst3_stateful_fixture.cpp b/tests/r0_2/vst3_stateful_fixture.cpp new file mode 100644 index 0000000..aa452bc --- /dev/null +++ b/tests/r0_2/vst3_stateful_fixture.cpp @@ -0,0 +1,234 @@ +#include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/ivstparameterchanges.h" +#include "pluginterfaces/vst/vstspeaker.h" +#include "public.sdk/source/main/pluginfactory.h" +#include "public.sdk/source/vst/vstaudioeffect.h" +#include "public.sdk/source/vst/vsteditcontroller.h" + +#include + +namespace safevst3::r0_2_fixture { + +using namespace Steinberg; +using namespace Steinberg::Vst; + +static const FUID kProcessorUid(0x5A36F1B2, 0x2C834761, 0x9E1507C4, 0xB1A2D301); +static const FUID kControllerUid(0xA861D24C, 0xDF0C4E32, 0x83B1F705, 0x26C9E402); +constexpr ParamID kGainId = 7001; +constexpr std::uint32_t kLatencySamples = 37; +constexpr std::uint32_t kComponentMagic = 0x52303243u; // R02C +constexpr std::uint32_t kControllerMagic = 0x52303255u; // R02U +constexpr std::int32_t kControllerCookie = 0x2468; + +namespace { + +template +bool write_exact(IBStream* stream, const T& value) +{ + if (!stream) + return false; + T copy = value; + int32 written = 0; + return stream->write(©, static_cast(sizeof(copy)), &written) == kResultOk && + written == static_cast(sizeof(copy)); +} + +template +bool read_exact(IBStream* stream, T& value) +{ + if (!stream) + return false; + int32 read = 0; + return stream->read(&value, static_cast(sizeof(value)), &read) == kResultOk && + read == static_cast(sizeof(value)); +} + +} // namespace + +class StatefulProcessor final : public AudioEffect { +public: + StatefulProcessor() + { + setControllerClass(kControllerUid); + } + + static FUnknown* create_instance(void*) + { + return static_cast(new StatefulProcessor()); + } + + tresult PLUGIN_API initialize(FUnknown* context) override + { + const tresult result = AudioEffect::initialize(context); + if (result != kResultOk) + return result; + addAudioInput(STR16("Input"), SpeakerArr::kStereo); + addAudioOutput(STR16("Output"), SpeakerArr::kStereo); + 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; + return inputs[0] == SpeakerArr::kStereo && outputs[0] == SpeakerArr::kStereo + ? kResultTrue + : kResultFalse; + } + + tresult PLUGIN_API setProcessing(TBool) override + { + return kResultTrue; + } + + tresult PLUGIN_API canProcessSampleSize(int32 symbolic_sample_size) override + { + return symbolic_sample_size == kSample32 ? kResultTrue : kResultFalse; + } + + uint32 PLUGIN_API getLatencySamples() override + { + return kLatencySamples; + } + + tresult PLUGIN_API process(ProcessData& data) override + { + if (data.numSamples < 0 || data.numInputs != 1 || data.numOutputs != 1 || + !data.inputs || !data.outputs) + return kResultFalse; + + if (data.inputParameterChanges) { + const int32 parameter_count = data.inputParameterChanges->getParameterCount(); + for (int32 index = 0; index < parameter_count; ++index) { + auto* queue = data.inputParameterChanges->getParameterData(index); + if (!queue || queue->getParameterId() != kGainId || queue->getPointCount() <= 0) + continue; + int32 sample_offset = 0; + ParamValue value = gain_normalized_; + if (queue->getPoint(queue->getPointCount() - 1, sample_offset, value) == kResultTrue) + gain_normalized_ = value; + } + } + + if (data.numSamples == 0) + return kResultOk; + if (data.inputs[0].numChannels != 2 || data.outputs[0].numChannels != 2 || + !data.inputs[0].channelBuffers32 || !data.outputs[0].channelBuffers32) + return kResultFalse; + + const float gain = 1.0f + static_cast(gain_normalized_); + for (int32 channel = 0; channel < 2; ++channel) { + auto* input = data.inputs[0].channelBuffers32[channel]; + auto* output = data.outputs[0].channelBuffers32[channel]; + if (!input || !output) + return kResultFalse; + for (int32 frame = 0; frame < data.numSamples; ++frame) + output[frame] = input[frame] * gain; + } + data.outputs[0].silenceFlags = 0; + return kResultOk; + } + + tresult PLUGIN_API getState(IBStream* state) override + { + return write_exact(state, kComponentMagic) && write_exact(state, gain_normalized_) + ? kResultTrue + : kResultFalse; + } + + tresult PLUGIN_API setState(IBStream* state) override + { + std::uint32_t magic = 0; + ParamValue gain = 0.0; + if (!read_exact(state, magic) || magic != kComponentMagic || !read_exact(state, gain)) + return kResultFalse; + gain_normalized_ = gain; + return kResultTrue; + } + +private: + ParamValue gain_normalized_ = 0.5; +}; + +class StatefulController final : public EditController { +public: + static FUnknown* create_instance(void*) + { + return static_cast(new StatefulController()); + } + + tresult PLUGIN_API initialize(FUnknown* context) override + { + const tresult result = EditController::initialize(context); + if (result != kResultOk) + return result; + parameters.addParameter( + STR16("Gain"), nullptr, 0, 0.5, ParameterInfo::kCanAutomate, kGainId); + return kResultOk; + } + + tresult PLUGIN_API setComponentState(IBStream* state) override + { + std::uint32_t magic = 0; + ParamValue gain = 0.0; + if (!read_exact(state, magic) || magic != kComponentMagic || !read_exact(state, gain)) + return kResultFalse; + return setParamNormalized(kGainId, gain); + } + + tresult PLUGIN_API getState(IBStream* state) override + { + return write_exact(state, kControllerMagic) && write_exact(state, controller_cookie_) + ? kResultTrue + : kResultFalse; + } + + tresult PLUGIN_API setState(IBStream* state) override + { + std::uint32_t magic = 0; + std::int32_t cookie = 0; + if (!read_exact(state, magic) || magic != kControllerMagic || !read_exact(state, cookie)) + return kResultFalse; + controller_cookie_ = cookie; + return kResultTrue; + } + +private: + std::int32_t controller_cookie_ = kControllerCookie; +}; + +} // namespace safevst3::r0_2_fixture + +using namespace Steinberg; +using namespace Steinberg::Vst; +using safevst3::r0_2_fixture::StatefulController; +using safevst3::r0_2_fixture::StatefulProcessor; +using safevst3::r0_2_fixture::kControllerUid; +using safevst3::r0_2_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, + "SafeVST3 R0-2 Stateful Fixture", + 0, + "Fx", + "1.0.0", + kVstVersionString, + StatefulProcessor::create_instance) + +DEF_CLASS2(INLINE_UID_FROM_FUID(kControllerUid), + PClassInfo::kManyInstances, + kVstComponentControllerClass, + "SafeVST3 R0-2 Stateful Fixture Controller", + 0, + "", + "1.0.0", + kVstVersionString, + StatefulController::create_instance) + +END_FACTORY