Skip to main content

truce_rack_vst3/
lib.rs

1//! VST3 host implementation for the truce-rack framework.
2//!
3//! Built on the community `vst3` Rust bindings — no Steinberg SDK
4//! submodule, no cmake. A fresh checkout builds in seconds.
5//!
6//! # Lifecycle
7//!
8//! Each `.vst3` bundle exports `GetPluginFactory` (and on macOS
9//! `bundleEntry` / `bundleExit`). Loading walks:
10//!
11//! 1. `dlopen` the binary inside the bundle.
12//! 2. `bundleEntry()` on macOS (no-op elsewhere).
13//! 3. `GetPluginFactory()` → `IPluginFactory`.
14//! 4. `createInstance(cid, IComponent::IID)` → `IComponent`.
15//! 5. `IPluginBase::initialize(host_context)` on the component.
16//! 6. Cast to `IAudioProcessor`.
17//! 7. `IComponent::getControllerClassId(&mut cid2)` — if separate,
18//!    `factory.createInstance(cid2, IEditController::IID)` and
19//!    initialize it. Otherwise cast the component itself.
20//! 8. Connect the component and controller `IConnectionPoint`s
21//!    (separate-controller case only).
22//!
23//! Activate calls `setBusArrangements`, `setupProcessing`,
24//! `setActive(true)`, `setProcessing(true)`. Deactivate reverses.
25
26use truce_rack_core::buffer::AudioBuffer;
27use truce_rack_core::bus::BusLayout;
28use truce_rack_core::error::{Error, Result};
29use truce_rack_core::events::EventList;
30use truce_rack_core::info::{ParameterInfo, PluginCategory, PluginInfo, PresetInfo};
31use truce_rack_core::plugin::{Plugin, PluginCore, ProcessContext, ProcessStatus};
32use truce_rack_core::scanner::PluginScanner;
33use truce_rack_core::transport::TransportInfo;
34use truce_rack_core::wrapper::run_audio_block_with;
35
36use std::path::{Path, PathBuf};
37use std::ptr;
38
39use vst3::Steinberg::Vst::{
40    AudioBusBuffers, AudioBusBuffers__type0, Event, Event_::EventTypes_, Event__type0,
41    IAudioProcessor, IAudioProcessorTrait, IComponent, IComponentTrait, IConnectionPoint,
42    IConnectionPointTrait, IEditController, IEditControllerTrait, IEventList, IEventListTrait,
43    IParameterChanges, NoteOffEvent, NoteOnEvent, ParameterInfo as Vst3ParameterInfo,
44    ParameterInfo_::ParameterFlags_, PolyPressureEvent, ProcessContext as Vst3ProcessContext,
45    ProcessContext_::StatesAndFlags_, ProcessData, ProcessModes_, ProcessSetup,
46    SymbolicSampleSizes_, ViewType,
47};
48use vst3::Steinberg::{
49    IBStream, IBStreamTrait, IPlugView, IPlugViewTrait, IPluginBaseTrait, IPluginFactory,
50    IPluginFactoryTrait, PClassInfo, PClassInfo_, TUID, ViewRect, kPlatformTypeHWND,
51    kPlatformTypeNSView, kPlatformTypeX11EmbedWindowID, kResultOk, kResultTrue,
52};
53use vst3::{Class, ComPtr, ComWrapper};
54
55/// Format identifier used on returned [`PluginInfo`].
56pub const FORMAT: &str = "vst3";
57
58/// Bundle directory suffix every VST3 plugin uses.
59pub const VST3_EXTENSION: &str = ".vst3";
60
61/// Symbol name `GetPluginFactory` plugins export. Same on every OS.
62const GET_FACTORY_SYMBOL: &[u8] = b"GetPluginFactory\0";
63
64/// macOS-only bundle entry point.
65#[cfg(target_os = "macos")]
66const BUNDLE_ENTRY_SYMBOL: &[u8] = b"bundleEntry\0";
67
68/// macOS-only bundle exit point.
69#[cfg(target_os = "macos")]
70const BUNDLE_EXIT_SYMBOL: &[u8] = b"bundleExit\0";
71
72/// Stereo speaker arrangement = `kSpeakerL | kSpeakerR`. Defined
73/// here to avoid a `kSpeaker*` import dance.
74const STEREO_ARRANGEMENT: u64 = 0x03;
75
76/// VST3 scanner.
77#[derive(Debug, Default)]
78pub struct Vst3Scanner;
79
80impl Vst3Scanner {
81    /// Construct a default scanner.
82    #[must_use]
83    pub fn new() -> Self {
84        Self
85    }
86}
87
88impl PluginScanner for Vst3Scanner {
89    type Plugin = Vst3Plugin;
90
91    fn scan(&self) -> Result<Vec<PluginInfo>> {
92        let mut out = Vec::new();
93        for dir in default_vst3_paths() {
94            if dir.exists() {
95                scan_dir_into(&dir, &mut out);
96            }
97        }
98        Ok(out)
99    }
100
101    fn scan_path(&self, path: &Path) -> Result<Vec<PluginInfo>> {
102        let mut out = Vec::new();
103        if path.exists() {
104            scan_dir_into(path, &mut out);
105        }
106        Ok(out)
107    }
108
109    fn load(&self, info: &PluginInfo) -> Result<Self::Plugin> {
110        Vst3Plugin::load_from(info)
111    }
112}
113
114/// Standard VST3 install locations for the current OS.
115#[must_use]
116pub fn default_vst3_paths() -> Vec<PathBuf> {
117    let mut out = Vec::new();
118    if let Some(home) = std::env::var_os("HOME") {
119        let mut user = PathBuf::from(home);
120        #[cfg(target_os = "macos")]
121        user.push("Library/Audio/Plug-Ins/VST3");
122        #[cfg(target_os = "linux")]
123        user.push(".vst3");
124        #[cfg(target_os = "windows")]
125        user.push("AppData/Local/Programs/Common/VST3");
126        out.push(user);
127    }
128    #[cfg(target_os = "macos")]
129    out.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
130    #[cfg(target_os = "linux")]
131    out.push(PathBuf::from("/usr/lib/vst3"));
132    #[cfg(target_os = "windows")]
133    {
134        if let Some(pf) = std::env::var_os("CommonProgramFiles") {
135            let mut p = PathBuf::from(pf);
136            p.push("VST3");
137            out.push(p);
138        }
139    }
140    out
141}
142
143fn scan_dir_into(dir: &Path, out: &mut Vec<PluginInfo>) {
144    let Ok(entries) = std::fs::read_dir(dir) else {
145        return;
146    };
147    for entry in entries.flatten() {
148        let path = entry.path();
149        let name = match path.file_name().and_then(|n| n.to_str()) {
150            Some(n) => n.to_string(),
151            None => continue,
152        };
153        if !name.ends_with(VST3_EXTENSION) {
154            continue;
155        }
156        if let Err(err) = scan_bundle_into(&path, out) {
157            eprintln!("[truce-rack-vst3] skipping {}: {err}", path.display());
158        }
159    }
160}
161
162fn scan_bundle_into(bundle: &Path, out: &mut Vec<PluginInfo>) -> Result<()> {
163    let module = unsafe { LoadedModule::open(bundle) }?;
164    let factory = module.factory()?;
165    let count = unsafe { factory.countClasses() };
166    let mut info = empty_pclass_info();
167    for idx in 0..count {
168        if unsafe { factory.getClassInfo(idx, &raw mut info) } != kResultOk {
169            continue;
170        }
171        let category = char8_array_to_string(&info.category);
172        if category != "Audio Module Class" {
173            continue;
174        }
175        let name = char8_array_to_string(&info.name);
176        out.push(PluginInfo {
177            name,
178            vendor: String::new(),
179            version: 0,
180            category: PluginCategory::Effect,
181            path: bundle.to_path_buf(),
182            unique_id: tuid_to_hex(&info.cid),
183            format: FORMAT,
184            has_editor: false,
185            accepts_midi: false,
186        });
187    }
188    Ok(())
189}
190
191fn empty_pclass_info() -> PClassInfo {
192    PClassInfo {
193        cid: [0; 16],
194        cardinality: 0,
195        category: [0; PClassInfo_::kCategorySize as usize],
196        name: [0; PClassInfo_::kNameSize as usize],
197    }
198}
199
200#[allow(clippy::cast_sign_loss)]
201fn char8_array_to_string(array: &[i8]) -> String {
202    // VST3 char8 is signed on Apple, unsigned elsewhere; the cast
203    // preserves bit pattern.
204    let bytes: Vec<u8> = array
205        .iter()
206        .take_while(|&&b| b != 0)
207        .map(|&b| b as u8)
208        .collect();
209    String::from_utf8_lossy(&bytes).into_owned()
210}
211
212#[allow(clippy::cast_sign_loss)]
213fn tuid_to_hex(cid: &[i8; 16]) -> String {
214    use std::fmt::Write;
215    let mut s = String::with_capacity(32);
216    for &b in cid {
217        let _ = write!(s, "{:02x}", b as u8);
218    }
219    s
220}
221
222#[allow(clippy::cast_possible_wrap)]
223fn hex_to_tuid(hex: &str) -> Option<TUID> {
224    if hex.len() != 32 {
225        return None;
226    }
227    let mut out: TUID = [0; 16];
228    for i in 0..16 {
229        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()? as i8;
230    }
231    Some(out)
232}
233
234#[cfg(target_os = "macos")]
235mod mac {
236    //! `CFBundle`-backed loader for VST3 bundles on macOS.
237    //!
238    //! Plugins like Surge XT Effects call into `CFPlugin` during
239    //! `bundleEntry` (specifically `CFPlugInRegisterFactories`,
240    //! which lives behind the `AddInstanceForFactory` log line).
241    //! Those APIs only work when the dylib was loaded through a
242    //! registered `CFBundle` — raw `dlopen` leaves the bundle
243    //! unknown to CoreFoundation and the plugin dereferences
244    //! garbage. Going through `CFBundleLoadExecutable` gives the
245    //! plugin the context it expects.
246
247    use std::path::{Path, PathBuf};
248
249    use core_foundation::base::TCFType;
250    use core_foundation::bundle::CFBundle;
251    use core_foundation::string::CFString;
252    use core_foundation::url::{CFURL, kCFURLPOSIXPathStyle};
253
254    use truce_rack_core::error::{Error, Result};
255
256    pub(super) struct MacBundle {
257        bundle: CFBundle,
258        path: PathBuf,
259    }
260
261    impl MacBundle {
262        pub(super) fn open(bundle_path: &Path) -> Result<Self> {
263            let path_str = bundle_path.to_str().ok_or_else(|| Error::LoadFailed {
264                path: bundle_path.to_path_buf(),
265                reason: "bundle path is not valid UTF-8".into(),
266            })?;
267            let cf_path = CFString::new(path_str);
268            // CFURLCreateWithFileSystemPath with `isDirectory = true`
269            // is what every reference VST3 host uses on macOS — a
270            // bundle URL has to be flagged as a directory or
271            // CFBundleCreate silently picks the wrong layout.
272            let url = unsafe {
273                use core_foundation_sys::base::kCFAllocatorDefault;
274                use core_foundation_sys::url::CFURLCreateWithFileSystemPath;
275                let raw = CFURLCreateWithFileSystemPath(
276                    kCFAllocatorDefault,
277                    cf_path.as_concrete_TypeRef(),
278                    kCFURLPOSIXPathStyle,
279                    1,
280                );
281                if raw.is_null() {
282                    return Err(Error::LoadFailed {
283                        path: bundle_path.to_path_buf(),
284                        reason: "CFURLCreateWithFileSystemPath returned NULL".into(),
285                    });
286                }
287                CFURL::wrap_under_create_rule(raw)
288            };
289
290            let bundle = CFBundle::new(url).ok_or_else(|| Error::LoadFailed {
291                path: bundle_path.to_path_buf(),
292                reason: "CFBundleCreate returned NULL".into(),
293            })?;
294
295            // CFBundleLoadExecutable must run before any function
296            // pointer lookup. Returns true on success.
297            let loaded = unsafe {
298                use core_foundation_sys::bundle::CFBundleLoadExecutable;
299                CFBundleLoadExecutable(bundle.as_concrete_TypeRef()) != 0
300            };
301            if !loaded {
302                return Err(Error::LoadFailed {
303                    path: bundle_path.to_path_buf(),
304                    reason: "CFBundleLoadExecutable failed".into(),
305                });
306            }
307
308            Ok(Self {
309                bundle,
310                path: bundle_path.to_path_buf(),
311            })
312        }
313
314        pub(super) fn path(&self) -> &Path {
315            &self.path
316        }
317
318        /// The raw `CFBundleRef`, type-erased to `*mut c_void` so
319        /// the call site doesn't need to depend on
320        /// `core-foundation-sys`. Hand this to `bundleEntry` —
321        /// it's the host's identity to the plugin.
322        pub(super) fn raw(&self) -> *mut std::ffi::c_void {
323            self.bundle.as_concrete_TypeRef().cast::<std::ffi::c_void>()
324        }
325
326        /// Look up an exported symbol. `name` may end in a trailing
327        /// NUL (we strip it before handing the text to `CFString`).
328        pub(super) unsafe fn function_ptr(&self, name: &[u8]) -> Option<*mut std::ffi::c_void> {
329            let name = match name.split_last() {
330                Some((0, rest)) => rest,
331                _ => name,
332            };
333            let name_str = std::str::from_utf8(name).ok()?;
334            let cf_name = CFString::new(name_str);
335            let ptr = unsafe {
336                use core_foundation_sys::bundle::CFBundleGetFunctionPointerForName;
337                CFBundleGetFunctionPointerForName(
338                    self.bundle.as_concrete_TypeRef(),
339                    cf_name.as_concrete_TypeRef(),
340                )
341            };
342            if ptr.is_null() {
343                None
344            } else {
345                Some(ptr.cast_mut().cast::<std::ffi::c_void>())
346            }
347        }
348    }
349
350    // SAFETY: CFBundle is reference-counted by CoreFoundation; we
351    // hold one owned reference and CoreFoundation itself is
352    // thread-safe for read access. The Drop impl releases the
353    // CFBundle but deliberately never calls
354    // `CFBundleUnloadExecutable` — VST3 plugins leave Objective-C
355    // class registrations and runloop callbacks pointing into the
356    // dylib, and unloading invalidates them. Same "don't dlclose"
357    // discipline truce-loader follows on the plugin side.
358    unsafe impl Send for MacBundle {}
359}
360
361/// Per-platform VST3 bundle layout. Linux uses
362/// `Contents/<arch>-linux/<stem>.so`; Windows uses
363/// `Contents/<arch>-win/<stem>.vst3`. macOS goes through CFBundle
364/// instead (see [`mac::MacBundle`]) so its binary lookup lives
365/// there.
366#[cfg(not(target_os = "macos"))]
367fn bundle_binary_path(bundle: &Path) -> PathBuf {
368    let stem = bundle
369        .file_stem()
370        .map(std::ffi::OsStr::to_os_string)
371        .unwrap_or_default();
372    #[cfg(target_os = "linux")]
373    {
374        if bundle.is_dir() {
375            let arch_dir = format!("{}-linux", std::env::consts::ARCH);
376            let mut binary = stem.clone();
377            binary.push(".so");
378            return bundle.join("Contents").join(arch_dir).join(binary);
379        }
380    }
381    #[cfg(target_os = "windows")]
382    {
383        if bundle.is_dir() {
384            let arch_dir = format!("{}-win", std::env::consts::ARCH);
385            let mut binary = stem.clone();
386            binary.push(".vst3");
387            return bundle.join("Contents").join(arch_dir).join(binary);
388        }
389    }
390    let _ = stem;
391    bundle.to_path_buf()
392}
393
394/// RAII wrapper around the loaded module. On macOS this is a real
395/// `CFBundle` so the plugin's `bundleEntry` sees the CFPlugin /
396/// CFBundleGetIdentifier context it expects (raw dlopen crashes
397/// some bundles — Surge XT Effects calls into CFPlugin's
398/// `AddInstanceForFactory` during init). On Linux / Windows the
399/// underlying file is a plain dynamic library; `libloading` is
400/// enough.
401#[cfg(not(target_os = "macos"))]
402struct LoadedModule {
403    library: libloading::Library,
404}
405
406#[cfg(target_os = "macos")]
407struct LoadedModule {
408    bundle: mac::MacBundle,
409    entered: bool,
410}
411
412#[cfg(not(target_os = "macos"))]
413impl LoadedModule {
414    unsafe fn open(bundle: &Path) -> Result<Self> {
415        let binary = bundle_binary_path(bundle);
416        let library =
417            unsafe { libloading::Library::new(&binary) }.map_err(|e| Error::LoadFailed {
418                path: bundle.to_path_buf(),
419                reason: format!("dlopen: {e}"),
420            })?;
421        Ok(Self { library })
422    }
423
424    fn factory(&self) -> Result<ComPtr<IPluginFactory>> {
425        let get_factory: libloading::Symbol<'_, unsafe extern "C" fn() -> *mut IPluginFactory> =
426            unsafe { self.library.get(GET_FACTORY_SYMBOL) }.map_err(|e| Error::LoadFailed {
427                path: PathBuf::new(),
428                reason: format!("missing GetPluginFactory: {e}"),
429            })?;
430        let ptr = unsafe { get_factory() };
431        let factory = unsafe { ComPtr::<IPluginFactory>::from_raw(ptr) }.ok_or_else(|| {
432            Error::LoadFailed {
433                path: PathBuf::new(),
434                reason: "GetPluginFactory returned NULL".into(),
435            }
436        })?;
437        Ok(factory)
438    }
439}
440
441#[cfg(target_os = "macos")]
442impl LoadedModule {
443    unsafe fn open(bundle: &Path) -> Result<Self> {
444        let mac_bundle = mac::MacBundle::open(bundle)?;
445
446        // VST3 macOS spec: bundleEntry takes the CFBundleRef the
447        // host loaded the plugin from. Surge XT Effects (and any
448        // bundle that touches CFPlugin/AU registration in init)
449        // dereferences that argument; passing nothing crashes
450        // inside CFRetain on a register that happened to be
451        // non-zero. Reference impl: Steinberg's `module_mac.mm`.
452        let entered = unsafe {
453            match mac_bundle.function_ptr(BUNDLE_ENTRY_SYMBOL) {
454                Some(ptr) => {
455                    let entry: unsafe extern "C" fn(*mut std::ffi::c_void) -> bool =
456                        std::mem::transmute(ptr);
457                    entry(mac_bundle.raw())
458                }
459                None => false,
460            }
461        };
462
463        Ok(Self {
464            bundle: mac_bundle,
465            entered,
466        })
467    }
468
469    fn factory(&self) -> Result<ComPtr<IPluginFactory>> {
470        let raw = unsafe {
471            self.bundle
472                .function_ptr(GET_FACTORY_SYMBOL)
473                .ok_or_else(|| Error::LoadFailed {
474                    path: self.bundle.path().to_path_buf(),
475                    reason: "missing GetPluginFactory".into(),
476                })?
477        };
478        let get_factory: unsafe extern "C" fn() -> *mut IPluginFactory =
479            unsafe { std::mem::transmute(raw) };
480        let ptr = unsafe { get_factory() };
481        let factory = unsafe { ComPtr::<IPluginFactory>::from_raw(ptr) }.ok_or_else(|| {
482            Error::LoadFailed {
483                path: self.bundle.path().to_path_buf(),
484                reason: "GetPluginFactory returned NULL".into(),
485            }
486        })?;
487        Ok(factory)
488    }
489}
490
491#[cfg(target_os = "macos")]
492impl Drop for LoadedModule {
493    fn drop(&mut self) {
494        // bundleExit is the symmetric partner to bundleEntry and
495        // takes no arguments per the VST3 macOS spec — only
496        // bundleEntry sees the CFBundleRef. Most plugins are
497        // no-ops; some unregister CFPlugin factories here.
498        if self.entered
499            && let Some(ptr) = unsafe { self.bundle.function_ptr(BUNDLE_EXIT_SYMBOL) }
500        {
501            let exit: unsafe extern "C" fn() -> bool = unsafe { std::mem::transmute(ptr) };
502            unsafe {
503                exit();
504            }
505        }
506    }
507}
508
509/// One loaded VST3 plugin instance.
510///
511/// Holds three COM smart pointers — component, audio processor,
512/// edit controller — plus the dlopen handle that keeps the
513/// underlying dylib mapped. When `Drop`s, the COM pointers
514/// release their objects which triggers `terminate()` and
515/// component disposal.
516pub struct Vst3Plugin {
517    info: PluginInfo,
518    layouts: Vec<BusLayout>,
519    active_layout: Option<BusLayout>,
520
521    // Hold the module open for the lifetime of the instance.
522    _module: LoadedModule,
523    component: ComPtr<IComponent>,
524    processor: ComPtr<IAudioProcessor>,
525    controller: ComPtr<IEditController>,
526    /// `true` when controller and component are *different* COM
527    /// objects (separate-controller architecture) and we've wired
528    /// their connection points.
529    separate_controller: bool,
530    component_cp: Option<ComPtr<IConnectionPoint>>,
531    controller_cp: Option<ComPtr<IConnectionPoint>>,
532
533    param_count: usize,
534    processing: bool,
535
536    /// Cached `IPlugView` for the plugin's editor. Created on
537    /// `open()`, released on `close()` / Drop.
538    view: Option<ComPtr<IPlugView>>,
539    editor_open: bool,
540}
541
542impl Vst3Plugin {
543    fn load_from(info: &PluginInfo) -> Result<Self> {
544        let module = unsafe { LoadedModule::open(&info.path) }?;
545        let factory = module.factory()?;
546
547        let cid = hex_to_tuid(&info.unique_id).ok_or_else(|| Error::LoadFailed {
548            path: info.path.clone(),
549            reason: format!("could not parse VST3 unique_id {:?}", info.unique_id),
550        })?;
551
552        // Create IComponent.
553        let component_ptr =
554            unsafe { create_instance::<IComponent>(&factory, &cid) }.ok_or_else(|| {
555                Error::LoadFailed {
556                    path: info.path.clone(),
557                    reason: "factory.createInstance(IComponent) returned NULL".into(),
558                }
559            })?;
560        let component = component_ptr;
561
562        // Initialize the component. Many plugins accept a NULL
563        // context (hosts only need to supply IHostApplication for
564        // plugins that look it up via queryInterface).
565        if unsafe { component.initialize(ptr::null_mut()) } != kResultOk {
566            return Err(Error::LoadFailed {
567                path: info.path.clone(),
568                reason: "IComponent::initialize returned non-OK".into(),
569            });
570        }
571
572        // Cast to IAudioProcessor.
573        let processor = component
574            .as_com_ref()
575            .cast::<IAudioProcessor>()
576            .ok_or_else(|| Error::LoadFailed {
577                path: info.path.clone(),
578                reason: "component does not implement IAudioProcessor".into(),
579            })?;
580
581        // Look up the separate-controller class id; if present we
582        // create a separate controller and connect to it. Otherwise
583        // the component is itself the controller.
584        let mut controller_cid: TUID = [0; 16];
585        let cls_id_status = unsafe { component.getControllerClassId(&raw mut controller_cid) };
586        let (controller, separate_controller) = if cls_id_status == kResultTrue {
587            let ctrl_ptr = unsafe { create_instance::<IEditController>(&factory, &controller_cid) }
588                .ok_or_else(|| Error::LoadFailed {
589                    path: info.path.clone(),
590                    reason: "factory.createInstance(IEditController) returned NULL".into(),
591                })?;
592            if unsafe { ctrl_ptr.initialize(ptr::null_mut()) } != kResultOk {
593                return Err(Error::LoadFailed {
594                    path: info.path.clone(),
595                    reason: "IEditController::initialize returned non-OK".into(),
596                });
597            }
598            (ctrl_ptr, true)
599        } else {
600            // Single-component plugin — controller and component
601            // share an object. Cast through queryInterface so the
602            // refcount is correct.
603            let ctrl = component
604                .as_com_ref()
605                .cast::<IEditController>()
606                .ok_or_else(|| Error::LoadFailed {
607                    path: info.path.clone(),
608                    reason:
609                        "component is not its own controller and didn't report a controller cid"
610                            .into(),
611                })?;
612            (ctrl, false)
613        };
614
615        // Optional: connect the two connection points so the
616        // plugin's component and controller can talk to each
617        // other (param/audio synchronisation). Best-effort —
618        // some plugins skip these even when the controller is
619        // separate.
620        let (component_cp, controller_cp) = if separate_controller {
621            let cp_a = component.as_com_ref().cast::<IConnectionPoint>();
622            let cp_b = controller.as_com_ref().cast::<IConnectionPoint>();
623            if let (Some(a), Some(b)) = (&cp_a, &cp_b) {
624                unsafe {
625                    a.connect(b.as_com_ref().as_ptr().cast());
626                    b.connect(a.as_com_ref().as_ptr().cast());
627                }
628            }
629            (cp_a, cp_b)
630        } else {
631            (None, None)
632        };
633
634        let param_count_raw = unsafe { controller.getParameterCount() }.max(0);
635        #[allow(clippy::cast_sign_loss)]
636        let param_count = param_count_raw as usize;
637
638        let mut info = info.clone();
639        // The editor exists if `createView("editor")` returns a
640        // non-null view. We probe by creating + releasing it once
641        // at load time. (Some plugins are slow to create the view;
642        // a heavier-weight host would defer this to first open.)
643        info.has_editor = unsafe { create_editor_view(&controller) }.is_some();
644
645        Ok(Self {
646            info,
647            layouts: vec![BusLayout::stereo()],
648            active_layout: None,
649            _module: module,
650            component,
651            processor,
652            controller,
653            separate_controller,
654            component_cp,
655            controller_cp,
656            param_count,
657            processing: false,
658            view: None,
659            editor_open: false,
660        })
661    }
662}
663
664/// Call `IEditController::createView("editor")` and wrap the raw
665/// pointer. Returns `None` when the plugin has no editor.
666unsafe fn create_editor_view(controller: &ComPtr<IEditController>) -> Option<ComPtr<IPlugView>> {
667    let raw = unsafe { controller.createView(ViewType::kEditor) };
668    if raw.is_null() {
669        return None;
670    }
671    unsafe { ComPtr::<IPlugView>::from_raw(raw) }
672}
673
674fn platform_type_for_handle(
675    handle: truce_rack_core::editor::WindowHandle,
676) -> (*const i8, *mut std::ffi::c_void) {
677    use truce_rack_core::editor::WindowHandle;
678    match handle {
679        WindowHandle::NSView(p) => (kPlatformTypeNSView, p),
680        WindowHandle::HWND(p) => (kPlatformTypeHWND, p),
681        WindowHandle::X11(id) => (kPlatformTypeX11EmbedWindowID, id as *mut std::ffi::c_void),
682    }
683}
684
685/// Run `factory.createInstance` for `I` and wrap the result as a
686/// `ComPtr<I>`. The factory call uses the interface's `IID`
687/// directly so the plugin returns the correct `*mut c_void`.
688unsafe fn create_instance<I>(factory: &ComPtr<IPluginFactory>, cid: &TUID) -> Option<ComPtr<I>>
689where
690    I: vst3::Interface,
691{
692    let mut obj: *mut std::ffi::c_void = ptr::null_mut();
693    // `com_scrape_types::Guid` and `TUID` share the same
694    // `[i8; 16]` layout — reinterpret to avoid an extra copy.
695    let iid_bytes: &TUID =
696        unsafe { &*(std::ptr::from_ref::<vst3::com_scrape_types::Guid>(&I::IID).cast::<TUID>()) };
697    let cid_ptr = cid.as_ptr();
698    let iid_ptr = iid_bytes.as_ptr();
699    if unsafe { factory.createInstance(cid_ptr, iid_ptr, &raw mut obj) } != kResultOk
700        || obj.is_null()
701    {
702        return None;
703    }
704    unsafe { ComPtr::<I>::from_raw(obj.cast()) }
705}
706
707impl Drop for Vst3Plugin {
708    fn drop(&mut self) {
709        if self.processing {
710            unsafe { self.processor.setProcessing(0) };
711        }
712        if self.active_layout.is_some() {
713            unsafe { self.component.setActive(0) };
714        }
715        if let (Some(a), Some(b)) = (&self.component_cp, &self.controller_cp) {
716            unsafe {
717                a.disconnect(b.as_com_ref().as_ptr().cast());
718                b.disconnect(a.as_com_ref().as_ptr().cast());
719            }
720        }
721        if self.separate_controller {
722            unsafe { self.controller.terminate() };
723        }
724        unsafe { self.component.terminate() };
725    }
726}
727
728impl PluginCore for Vst3Plugin {
729    fn info(&self) -> &PluginInfo {
730        &self.info
731    }
732    fn active_layout(&self) -> Option<&BusLayout> {
733        self.active_layout.as_ref()
734    }
735    fn supported_layouts(&self) -> &[BusLayout] {
736        &self.layouts
737    }
738
739    fn parameter_count(&self) -> usize {
740        self.param_count
741    }
742
743    fn parameter_info(&self, index: usize) -> Result<ParameterInfo> {
744        if index >= self.param_count {
745            return Err(Error::InvalidParameter(index));
746        }
747        let mut info = empty_parameter_info();
748        let i32_index = i32::try_from(index).map_err(|_| Error::InvalidParameter(index))?;
749        if unsafe { self.controller.getParameterInfo(i32_index, &raw mut info) } != kResultOk {
750            return Err(Error::InvalidParameter(index));
751        }
752        Ok(vst3_param_info_to_rack(&info))
753    }
754
755    fn parameter_value(&self, index: usize) -> Result<f64> {
756        if index >= self.param_count {
757            return Err(Error::InvalidParameter(index));
758        }
759        let mut info = empty_parameter_info();
760        let i32_index = i32::try_from(index).map_err(|_| Error::InvalidParameter(index))?;
761        if unsafe { self.controller.getParameterInfo(i32_index, &raw mut info) } != kResultOk {
762            return Err(Error::InvalidParameter(index));
763        }
764        Ok(unsafe { self.controller.getParamNormalized(info.id) })
765    }
766
767    fn parameter_value_string(&self, index: usize, _value: f64) -> Result<String> {
768        // VST3 exposes IEditController::getParamStringByValue —
769        // wiring it requires a TChar (UTF-16) buffer round-trip.
770        // Tracked as a follow-on to avoid pulling in a wide-char
771        // dep here.
772        let _ = index;
773        Err(Error::Other("vst3 parameter_value_string TODO".into()))
774    }
775
776    fn set_parameter(&mut self, index: usize, value: f64) -> Result<()> {
777        if index >= self.param_count {
778            return Err(Error::InvalidParameter(index));
779        }
780        let mut info = empty_parameter_info();
781        let i32_index = i32::try_from(index).map_err(|_| Error::InvalidParameter(index))?;
782        if unsafe { self.controller.getParameterInfo(i32_index, &raw mut info) } != kResultOk {
783            return Err(Error::InvalidParameter(index));
784        }
785        let clamped = value.clamp(0.0, 1.0);
786        if unsafe { self.controller.setParamNormalized(info.id, clamped) } != kResultOk {
787            return Err(Error::Other(
788                "IEditController::setParamNormalized failed".into(),
789            ));
790        }
791        Ok(())
792    }
793
794    fn preset_count(&self) -> usize {
795        // VST3 exposes presets via the `IUnitInfo` interface,
796        // wired in a follow-on. Treat as zero for now.
797        0
798    }
799    fn preset_info(&self, index: usize) -> Result<PresetInfo> {
800        Err(Error::InvalidParameter(index))
801    }
802    fn load_preset(&mut self, _preset_number: i32) -> Result<()> {
803        Err(Error::Other("vst3 preset loading not yet wired".into()))
804    }
805
806    fn save_state(&self) -> Result<Vec<u8>> {
807        let stream = ComWrapper::new(MemoryStream::default());
808        let stream_ptr = stream
809            .to_com_ptr::<IBStream>()
810            .ok_or_else(|| Error::Other("MemoryStream missing IBStream IID".into()))?;
811        let status = unsafe { self.component.getState(stream_ptr.as_ptr()) };
812        if status != kResultOk {
813            return Err(Error::Other(format!(
814                "IComponent::getState returned {status}"
815            )));
816        }
817        Ok(stream.data.borrow().clone())
818    }
819
820    fn load_state(&mut self, bytes: &[u8]) -> Result<()> {
821        let stream = ComWrapper::new(MemoryStream {
822            data: std::cell::RefCell::new(bytes.to_vec()),
823            position: std::cell::Cell::new(0),
824        });
825        let stream_ptr = stream
826            .to_com_ptr::<IBStream>()
827            .ok_or_else(|| Error::Other("MemoryStream missing IBStream IID".into()))?;
828        let status = unsafe { self.component.setState(stream_ptr.as_ptr()) };
829        if status != kResultOk {
830            return Err(Error::Other(format!(
831                "IComponent::setState returned {status}"
832            )));
833        }
834        Ok(())
835    }
836
837    fn activate(
838        &mut self,
839        layout: BusLayout,
840        sample_rate: f64,
841        max_block_size: usize,
842    ) -> Result<()> {
843        let mut input_arr = STEREO_ARRANGEMENT;
844        let mut output_arr = STEREO_ARRANGEMENT;
845        let _ = unsafe {
846            self.processor
847                .setBusArrangements(&raw mut input_arr, 1, &raw mut output_arr, 1)
848        };
849
850        let mut setup = ProcessSetup {
851            #[allow(clippy::cast_possible_wrap)]
852            processMode: ProcessModes_::kRealtime as i32,
853            #[allow(clippy::cast_possible_wrap)]
854            symbolicSampleSize: SymbolicSampleSizes_::kSample32 as i32,
855            maxSamplesPerBlock: i32::try_from(max_block_size).unwrap_or(i32::MAX),
856            sampleRate: sample_rate,
857        };
858        if unsafe { self.processor.setupProcessing(&raw mut setup) } != kResultOk {
859            return Err(Error::Other(
860                "IAudioProcessor::setupProcessing failed".into(),
861            ));
862        }
863        if unsafe { self.component.setActive(1) } != kResultOk {
864            return Err(Error::Other("IComponent::setActive(true) failed".into()));
865        }
866        if unsafe { self.processor.setProcessing(1) } != kResultOk {
867            return Err(Error::Other(
868                "IAudioProcessor::setProcessing(true) failed".into(),
869            ));
870        }
871        self.processing = true;
872        self.active_layout = Some(layout);
873        Ok(())
874    }
875
876    fn deactivate(&mut self) {
877        if self.processing {
878            unsafe { self.processor.setProcessing(0) };
879            self.processing = false;
880        }
881        if self.active_layout.is_some() {
882            unsafe { self.component.setActive(0) };
883        }
884        self.active_layout = None;
885    }
886    fn is_active(&self) -> bool {
887        self.active_layout.is_some()
888    }
889
890    fn editor(&mut self) -> Option<&mut dyn truce_rack_core::editor::PluginEditor> {
891        if !self.info.has_editor {
892            return None;
893        }
894        Some(self)
895    }
896}
897
898impl truce_rack_core::editor::PluginEditor for Vst3Plugin {
899    fn open(
900        &mut self,
901        parent: truce_rack_core::editor::WindowHandle,
902        _scale: f64,
903    ) -> truce_rack_core::error::Result<()> {
904        if self.editor_open {
905            return Ok(());
906        }
907        let view = unsafe { create_editor_view(&self.controller) }
908            .ok_or_else(|| Error::Other("IEditController::createView returned NULL".into()))?;
909        let (type_str, parent_ptr) = platform_type_for_handle(parent);
910        if unsafe { view.isPlatformTypeSupported(type_str) } != kResultOk {
911            return Err(Error::Other(
912                "IPlugView::isPlatformTypeSupported returned false".into(),
913            ));
914        }
915        if unsafe { view.attached(parent_ptr, type_str) } != kResultOk {
916            return Err(Error::Other("IPlugView::attached returned non-OK".into()));
917        }
918        self.view = Some(view);
919        self.editor_open = true;
920        Ok(())
921    }
922
923    fn close(&mut self) {
924        if let Some(view) = self.view.take() {
925            unsafe { view.removed() };
926        }
927        self.editor_open = false;
928    }
929
930    fn is_open(&self) -> bool {
931        self.editor_open
932    }
933
934    fn size(&self) -> Option<(u32, u32)> {
935        let view = self.view.as_ref()?;
936        let mut rect = ViewRect {
937            left: 0,
938            top: 0,
939            right: 0,
940            bottom: 0,
941        };
942        if unsafe { view.getSize(&raw mut rect) } != kResultOk {
943            return None;
944        }
945        let w = u32::try_from(rect.right - rect.left).ok()?;
946        let h = u32::try_from(rect.bottom - rect.top).ok()?;
947        Some((w, h))
948    }
949
950    fn is_resizable(&self) -> bool {
951        let Some(view) = self.view.as_ref() else {
952            return false;
953        };
954        unsafe { view.canResize() == kResultOk }
955    }
956
957    fn set_size(&mut self, width: u32, height: u32) -> Option<(u32, u32)> {
958        let view = self.view.as_ref()?;
959        let mut rect = ViewRect {
960            left: 0,
961            top: 0,
962            right: i32::try_from(width).ok()?,
963            bottom: i32::try_from(height).ok()?,
964        };
965        // Plugins may snap to a constraint via checkSizeConstraint.
966        let _ = unsafe { view.checkSizeConstraint(&raw mut rect) };
967        if unsafe { view.onSize(&raw mut rect) } != kResultOk {
968            return None;
969        }
970        let w = u32::try_from(rect.right - rect.left).ok()?;
971        let h = u32::try_from(rect.bottom - rect.top).ok()?;
972        Some((w, h))
973    }
974
975    fn show(&mut self) {
976        // VST3 attaches once and stays visible — no show/hide
977        // separate from attached/removed.
978    }
979
980    fn hide(&mut self) {
981        // Same as show — VST3 has no distinct hide.
982    }
983}
984
985fn empty_parameter_info() -> Vst3ParameterInfo {
986    Vst3ParameterInfo {
987        id: 0,
988        title: [0; 128],
989        shortTitle: [0; 128],
990        units: [0; 128],
991        stepCount: 0,
992        defaultNormalizedValue: 0.0,
993        unitId: 0,
994        flags: 0,
995    }
996}
997
998fn vst3_param_info_to_rack(info: &Vst3ParameterInfo) -> ParameterInfo {
999    let name = string128_to_string(&info.title);
1000    let short_name = string128_to_string(&info.shortTitle);
1001    let unit = string128_to_string(&info.units);
1002    let mut flags = truce_rack_core::info::ParameterFlags::empty();
1003    if info.flags & ParameterFlags_::kIsBypass != 0 {
1004        flags |= truce_rack_core::info::ParameterFlags::BYPASS;
1005    }
1006    if info.flags & ParameterFlags_::kCanAutomate != 0 {
1007        flags |= truce_rack_core::info::ParameterFlags::AUTOMATABLE;
1008    }
1009    if info.flags & ParameterFlags_::kIsHidden != 0 {
1010        flags |= truce_rack_core::info::ParameterFlags::HIDDEN;
1011    }
1012    if info.flags & ParameterFlags_::kIsReadOnly != 0 {
1013        flags |= truce_rack_core::info::ParameterFlags::READ_ONLY;
1014    }
1015    if info.flags & ParameterFlags_::kIsList != 0 {
1016        flags |= truce_rack_core::info::ParameterFlags::ENUMERATED;
1017    }
1018    ParameterInfo {
1019        id: info.id,
1020        name,
1021        short_name,
1022        unit,
1023        min: 0.0,
1024        max: 1.0,
1025        default: info.defaultNormalizedValue,
1026        step_count: u32::try_from(info.stepCount).unwrap_or(0),
1027        flags,
1028    }
1029}
1030
1031/// VST3 `String128` is `[char16; 128]` (UTF-16). Walk the slice
1032/// until the first NUL and decode to UTF-8.
1033fn string128_to_string(buf: &[u16; 128]) -> String {
1034    let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
1035    String::from_utf16_lossy(&buf[..len])
1036}
1037
1038/// Translate a host [`TransportInfo`] snapshot into Steinberg's
1039/// `Vst::ProcessContext`. Only the fields the host reported get
1040/// their `k…Valid` flag set; everything else stays zero so the
1041/// plugin treats it as absent.
1042// `StatesAndFlags` is `c_int` or `c_uint` depending on platform, so
1043// the `as u32` casts are only redundant on some targets.
1044#[allow(clippy::unnecessary_cast)]
1045fn build_vst3_context(t: &TransportInfo, sample_rate: f64) -> Vst3ProcessContext {
1046    // SAFETY: ProcessContext is a repr(C) aggregate of integers,
1047    // floats, and small POD sub-structs (Chord, FrameRate). An
1048    // all-zero value is the valid "no flags set" state; we then
1049    // fill in the fields the host actually reported.
1050    let mut ctx: Vst3ProcessContext = unsafe { std::mem::zeroed() };
1051    ctx.sampleRate = sample_rate;
1052
1053    let mut state: u32 = 0;
1054    if let Some(tempo) = t.tempo_bpm {
1055        ctx.tempo = tempo;
1056        state |= StatesAndFlags_::kTempoValid as u32;
1057    }
1058    if let Some((num, den)) = t.time_signature {
1059        ctx.timeSigNumerator = i32::try_from(num).unwrap_or(4);
1060        ctx.timeSigDenominator = i32::try_from(den).unwrap_or(4);
1061        state |= StatesAndFlags_::kTimeSigValid as u32;
1062    }
1063    if let Some(beats) = t.song_position_beats {
1064        // projectTimeMusic is in quarter notes (TQuarterNotes = f64).
1065        ctx.projectTimeMusic = beats;
1066        state |= StatesAndFlags_::kProjectTimeMusicValid as u32;
1067    }
1068    if let Some(samples) = t.song_position_samples {
1069        ctx.projectTimeSamples = samples;
1070    }
1071    if let Some(bar) = t.bar_start_beats {
1072        ctx.barPositionMusic = bar;
1073        state |= StatesAndFlags_::kBarPositionValid as u32;
1074    }
1075    if t.playing {
1076        state |= StatesAndFlags_::kPlaying as u32;
1077    }
1078    if t.recording {
1079        state |= StatesAndFlags_::kRecording as u32;
1080    }
1081    if t.loop_active {
1082        state |= StatesAndFlags_::kCycleActive as u32;
1083    }
1084    ctx.state = state;
1085    ctx
1086}
1087
1088impl Plugin<f32> for Vst3Plugin {
1089    fn process(
1090        &mut self,
1091        buffer: &mut AudioBuffer<'_, f32>,
1092        events: &EventList,
1093        context: &mut ProcessContext<'_>,
1094    ) -> Result<ProcessStatus> {
1095        if !self.is_active() {
1096            return Err(Error::NotActivated);
1097        }
1098        let frames = buffer.num_frames();
1099
1100        // Build an IEventList for the plugin's inputEvents slot.
1101        // We translate truce-rack MIDI events to VST3 Event structs.
1102        let mut translated = EventBuffer::default();
1103        for event in events {
1104            translated.push_rack(event);
1105        }
1106        let input_events_wrapper = ComWrapper::new(EventList3 {
1107            events: std::cell::RefCell::new(translated.events),
1108        });
1109        let input_events_ptr = input_events_wrapper
1110            .to_com_ptr::<IEventList>()
1111            .ok_or_else(|| Error::Other("EventList3 missing IEventList IID".into()))?;
1112        let output_events_wrapper = ComWrapper::new(EventList3::default());
1113        let output_events_ptr = output_events_wrapper
1114            .to_com_ptr::<IEventList>()
1115            .ok_or_else(|| Error::Other("EventList3 missing IEventList IID".into()))?;
1116
1117        let main_inputs = buffer.main_inputs();
1118        let mut input_ptrs: Vec<*mut f32> =
1119            main_inputs.iter().map(|c| c.as_ptr().cast_mut()).collect();
1120        let mut input_bus = AudioBusBuffers {
1121            numChannels: i32::try_from(input_ptrs.len()).unwrap_or(0),
1122            silenceFlags: 0,
1123            __field0: AudioBusBuffers__type0 {
1124                channelBuffers32: input_ptrs.as_mut_ptr(),
1125            },
1126        };
1127
1128        let main_outputs = buffer.main_outputs();
1129        let mut output_ptrs: Vec<*mut f32> =
1130            main_outputs.iter_mut().map(|c| c.as_mut_ptr()).collect();
1131        let mut output_bus = AudioBusBuffers {
1132            numChannels: i32::try_from(output_ptrs.len()).unwrap_or(0),
1133            silenceFlags: 0,
1134            __field0: AudioBusBuffers__type0 {
1135                channelBuffers32: output_ptrs.as_mut_ptr(),
1136            },
1137        };
1138
1139        // Build the transport context up front so its backing
1140        // struct outlives the process call.
1141        let mut process_context = context
1142            .transport
1143            .map(|t| build_vst3_context(&t, context.sample_rate));
1144
1145        let mut data = ProcessData {
1146            #[allow(clippy::cast_possible_wrap)]
1147            processMode: ProcessModes_::kRealtime as i32,
1148            #[allow(clippy::cast_possible_wrap)]
1149            symbolicSampleSize: SymbolicSampleSizes_::kSample32 as i32,
1150            numSamples: i32::try_from(frames).unwrap_or(i32::MAX),
1151            numInputs: 1,
1152            numOutputs: 1,
1153            inputs: &raw mut input_bus,
1154            outputs: &raw mut output_bus,
1155            inputParameterChanges: ptr::null_mut::<IParameterChanges>(),
1156            outputParameterChanges: ptr::null_mut::<IParameterChanges>(),
1157            inputEvents: input_events_ptr.as_ptr(),
1158            outputEvents: output_events_ptr.as_ptr(),
1159            processContext: process_context
1160                .as_mut()
1161                .map_or(ptr::null_mut(), std::ptr::from_mut),
1162        };
1163
1164        let processor_ptr = self.processor.as_ptr();
1165        let status = run_audio_block_with::<Vst3Plugin, i32>(FORMAT, -1, || unsafe {
1166            ((*(*processor_ptr).vtbl).process)(processor_ptr, &raw mut data)
1167        });
1168        if status == kResultOk {
1169            Ok(ProcessStatus::Continue)
1170        } else {
1171            Ok(ProcessStatus::Error)
1172        }
1173    }
1174}
1175
1176// ---------------------------------------------------------------------------
1177// EventList3 — in-memory IEventList impl for MIDI in/out.
1178// ---------------------------------------------------------------------------
1179
1180/// Translation buffer for truce-rack-core `EventList` → VST3 `Event[]`.
1181#[derive(Default)]
1182struct EventBuffer {
1183    events: Vec<Event>,
1184}
1185
1186impl EventBuffer {
1187    fn push_rack(&mut self, event: &truce_rack_core::events::Event) {
1188        use truce_rack_core::events::{EventBody, MidiData};
1189        let offset = i32::try_from(event.sample_offset).unwrap_or(i32::MAX);
1190        let EventBody::Midi(body) = event.body else {
1191            return;
1192        };
1193        let header = |type_: u16| Event {
1194            busIndex: 0,
1195            sampleOffset: offset,
1196            ppqPosition: 0.0,
1197            flags: 0,
1198            r#type: type_,
1199            __field0: Event__type0 {
1200                noteOn: NoteOnEvent {
1201                    channel: 0,
1202                    pitch: 0,
1203                    tuning: 0.0,
1204                    velocity: 0.0,
1205                    length: 0,
1206                    noteId: -1,
1207                },
1208            },
1209        };
1210        match body {
1211            MidiData::NoteOn {
1212                channel,
1213                note,
1214                velocity,
1215            } => {
1216                let mut ev = header(u16::try_from(EventTypes_::kNoteOnEvent).unwrap_or(0));
1217                ev.__field0 = Event__type0 {
1218                    noteOn: NoteOnEvent {
1219                        channel: i16::from(channel),
1220                        pitch: i16::from(note),
1221                        tuning: 0.0,
1222                        velocity: f32::from(velocity) / 127.0,
1223                        length: 0,
1224                        noteId: -1,
1225                    },
1226                };
1227                self.events.push(ev);
1228            }
1229            MidiData::NoteOff {
1230                channel,
1231                note,
1232                velocity,
1233            } => {
1234                let mut ev = header(u16::try_from(EventTypes_::kNoteOffEvent).unwrap_or(0));
1235                ev.__field0 = Event__type0 {
1236                    noteOff: NoteOffEvent {
1237                        channel: i16::from(channel),
1238                        pitch: i16::from(note),
1239                        velocity: f32::from(velocity) / 127.0,
1240                        noteId: -1,
1241                        tuning: 0.0,
1242                    },
1243                };
1244                self.events.push(ev);
1245            }
1246            MidiData::PolyAftertouch {
1247                channel,
1248                note,
1249                pressure,
1250            } => {
1251                let mut ev = header(u16::try_from(EventTypes_::kPolyPressureEvent).unwrap_or(0));
1252                ev.__field0 = Event__type0 {
1253                    polyPressure: PolyPressureEvent {
1254                        channel: i16::from(channel),
1255                        pitch: i16::from(note),
1256                        pressure: f32::from(pressure) / 127.0,
1257                        noteId: -1,
1258                    },
1259                };
1260                self.events.push(ev);
1261            }
1262            // VST3 routes CC / ProgramChange / ChannelAftertouch /
1263            // PitchBend / Sysex through IParameterChanges or
1264            // IMidiMapping rather than IEventList. Wiring those is
1265            // a follow-on; for hosting test purposes notes are the
1266            // primary need.
1267            _ => {}
1268        }
1269    }
1270}
1271
1272#[derive(Default)]
1273struct EventList3 {
1274    events: std::cell::RefCell<Vec<Event>>,
1275}
1276
1277impl Class for EventList3 {
1278    type Interfaces = (IEventList,);
1279}
1280
1281#[allow(clippy::cast_sign_loss)]
1282impl IEventListTrait for EventList3 {
1283    unsafe fn getEventCount(&self) -> i32 {
1284        i32::try_from(self.events.borrow().len()).unwrap_or(i32::MAX)
1285    }
1286
1287    unsafe fn getEvent(&self, index: i32, out: *mut Event) -> i32 {
1288        if out.is_null() || index < 0 {
1289            return -1;
1290        }
1291        let events = self.events.borrow();
1292        let Some(event) = events.get(index as usize) else {
1293            return -1;
1294        };
1295        unsafe { *out = *event };
1296        kResultOk
1297    }
1298
1299    unsafe fn addEvent(&self, event: *mut Event) -> i32 {
1300        if event.is_null() {
1301            return -1;
1302        }
1303        self.events.borrow_mut().push(unsafe { *event });
1304        kResultOk
1305    }
1306}
1307
1308// ---------------------------------------------------------------------------
1309// MemoryStream — in-memory IBStream impl for state save/load.
1310// ---------------------------------------------------------------------------
1311
1312/// Backing storage for the `IBStream` we hand to
1313/// `IComponent::setState` / `getState`. The plugin reads and
1314/// writes through `read`/`write`; the host inspects
1315/// `data` / `position` after the call.
1316#[derive(Default)]
1317struct MemoryStream {
1318    data: std::cell::RefCell<Vec<u8>>,
1319    position: std::cell::Cell<usize>,
1320}
1321
1322impl Class for MemoryStream {
1323    type Interfaces = (IBStream,);
1324}
1325
1326impl IBStreamTrait for MemoryStream {
1327    unsafe fn read(
1328        &self,
1329        buffer: *mut std::ffi::c_void,
1330        num_bytes: i32,
1331        num_bytes_read: *mut i32,
1332    ) -> i32 {
1333        if buffer.is_null() || num_bytes < 0 {
1334            return -1;
1335        }
1336        let pos = self.position.get();
1337        #[allow(clippy::cast_sign_loss)]
1338        let want = num_bytes as usize;
1339        let data = self.data.borrow();
1340        let available = data.len().saturating_sub(pos);
1341        let take = want.min(available);
1342        if take > 0 {
1343            unsafe {
1344                std::ptr::copy_nonoverlapping(data.as_ptr().add(pos), buffer.cast::<u8>(), take);
1345            }
1346        }
1347        self.position.set(pos + take);
1348        if !num_bytes_read.is_null() {
1349            unsafe {
1350                *num_bytes_read = i32::try_from(take).unwrap_or(i32::MAX);
1351            }
1352        }
1353        kResultOk
1354    }
1355
1356    unsafe fn write(
1357        &self,
1358        buffer: *mut std::ffi::c_void,
1359        num_bytes: i32,
1360        num_bytes_written: *mut i32,
1361    ) -> i32 {
1362        if buffer.is_null() || num_bytes < 0 {
1363            return -1;
1364        }
1365        let pos = self.position.get();
1366        #[allow(clippy::cast_sign_loss)]
1367        let want = num_bytes as usize;
1368        let mut data = self.data.borrow_mut();
1369        if data.len() < pos + want {
1370            data.resize(pos + want, 0);
1371        }
1372        unsafe {
1373            std::ptr::copy_nonoverlapping(buffer.cast::<u8>(), data.as_mut_ptr().add(pos), want);
1374        }
1375        self.position.set(pos + want);
1376        if !num_bytes_written.is_null() {
1377            unsafe {
1378                *num_bytes_written = i32::try_from(want).unwrap_or(i32::MAX);
1379            }
1380        }
1381        kResultOk
1382    }
1383
1384    #[allow(clippy::cast_possible_wrap)]
1385    unsafe fn seek(&self, pos: i64, mode: i32, result: *mut i64) -> i32 {
1386        // VST3 SDK SeekMode: 0 = `SeekSet`, 1 = `SeekCur`, 2 = `SeekEnd`.
1387        let data_len = self.data.borrow().len() as i64;
1388        let current = self.position.get() as i64;
1389        let new_pos = match mode {
1390            0 => pos,
1391            1 => current + pos,
1392            2 => data_len + pos,
1393            _ => return -1,
1394        };
1395        if new_pos < 0 {
1396            return -1;
1397        }
1398        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1399        self.position.set(new_pos as usize);
1400        if !result.is_null() {
1401            unsafe {
1402                *result = new_pos;
1403            }
1404        }
1405        kResultOk
1406    }
1407
1408    #[allow(clippy::cast_possible_wrap)]
1409    unsafe fn tell(&self, pos: *mut i64) -> i32 {
1410        if pos.is_null() {
1411            return -1;
1412        }
1413        unsafe {
1414            *pos = self.position.get() as i64;
1415        }
1416        kResultOk
1417    }
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423
1424    #[test]
1425    fn tuid_hex_roundtrip() {
1426        let cid: TUID = [
1427            0x01, 0x23, 0x45, 0x67, -0x10, -0x10, -0x10, -0x10, 0, 0, 0, 0, 0, 0, 0, 0,
1428        ];
1429        let hex = tuid_to_hex(&cid);
1430        let parsed = hex_to_tuid(&hex).expect("parse");
1431        assert_eq!(parsed, cid);
1432    }
1433
1434    #[test]
1435    fn char8_skips_trailing_nul() {
1436        let mut arr = [0i8; 16];
1437        for (i, &b) in b"Hello".iter().enumerate() {
1438            // ASCII byte → i8 always fits losslessly; the cast is
1439            // bit-pattern-identical for `b` < 128.
1440            arr[i] = i8::try_from(b).expect("ASCII byte fits in i8");
1441        }
1442        assert_eq!(char8_array_to_string(&arr), "Hello");
1443    }
1444}