From 245015fd89135eaa0f9b014091e4c8ed5fc0d86c Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Tue, 13 Oct 2026 14:55:34 +0200 Subject: [PATCH] VST3 host: IMessage and IAttributeList for plugins (Helix loads its database) `IHostApplication::createInstance` returned nothing, so plugins whose controller and processor exchange `IMessage`s through `IConnectionPoint::notify` could not create one. Line 6 Helix Stadium Native reported "Failed to get content database recovery info (Out of memory condition. [code -2])", loaded no models and exposed no parameters. The host now creates `IMessage` (an id plus its attribute list) and `IAttributeList` (ints, floats, UTF-16 strings and binary blobs, by NUL-terminated attribute id). Strings and blobs are kept in their own allocation so pointers from `getBinary` stay valid while the list holds the value; strings are capped at 1M UTF-16 units and blobs at 256 MiB. Null arguments are refused rather than read. Other classes are still not provided. Verified with Helix Stadium Native 1.03.3a on Windows: the error is gone, its model database and presets load, and its parameters appear in the generic plugin window. A unit test drives the objects through the COM interfaces the way a plugin does. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/vst3-host/src/ffi.rs | 267 +++++++++++++++++++++++++++++++++++- docs/plugin-parity.md | 3 +- 3 files changed, 264 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..094a9a7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-13 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change. - **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265) - **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246) - **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index d1ddea23..df42934a 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1,4 +1,4 @@ -//! The FFI boundary: the only module of this crate allowed to use `unsafe`. +//! The FFI boundary: the only module of this rate allowed to use `unsafe`. //! //! It loads VST3 binaries (`libloading`), runs the platform module entry, calls the COM //! interfaces of the factory, component, audio processor and edit controller (`vst3` raw @@ -646,13 +646,32 @@ impl IHostApplicationTrait for HostContext { } } - unsafe fn createInstance(&self, _cid: *mut sb::TUID, _iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult { - // IMessage / IAttributeList are not provided yet; plugins must cope with that. + /// Creates the objects plugins may ask the host for: `IMessage` (what a controller and its + /// processor send each other through `IConnectionPoint::notify`) and `IAttributeList`. + /// Plugins that talk between their halves this way fail without them (Line 6 Helix + /// reported "Failed to get content database recovery info (Out of memory condition)"). + unsafe fn createInstance(&self, cid: *mut sb::TUID, iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult { // SAFETY: `obj` is the plugin's out-pointer (or null, handled). - if let Some(o) = unsafe { obj.as_mut() } { - *o = std::ptr::null_mut(); + let Some(out) = (unsafe { obj.as_mut() }) else { return sb::kInvalidArgument }; + *out = std::ptr::null_mut(); + // SAFETY: `cid` and `iid` point to 16-byte TUIDs (or are null, handled). + let (Some(cid), Some(iid)) = (unsafe { cid.as_ref() }, unsafe { iid.as_ref() }) else { return sb::kInvalidArgument }; + let (cid, iid) = (tuid_bytes(cid), tuid_bytes(iid)); + let wants = |class: [u8; 16]| cid == class && (iid == class || iid == FUnknown::IID); + let made = if wants(sv::IMessage::IID) { + ComWrapper::new(HostMessage::default()).to_com_ptr::().map(|p| p.into_raw().cast::()) + } else if wants(sv::IAttributeList::IID) { + ComWrapper::new(HostAttributes::default()).to_com_ptr::().map(|p| p.into_raw().cast::()) + } else { + None + }; + match made { + Some(p) => { + *out = p; + sb::kResultOk + } + None => sb::kResultFalse, } - sb::kResultFalse } } @@ -684,6 +703,191 @@ impl IComponentHandlerTrait for HostContext { } } +/// Longest string attribute kept, in UTF-16 units (longer ones are cut). +const ATTR_STRING_MAX: usize = 1 << 20; +/// Largest binary attribute kept. +const ATTR_BINARY_MAX: usize = 256 << 20; + +/// One `IAttributeList` value. Strings and blobs live in their own allocation, so a pointer +/// handed out by `getBinary` stays valid while the list holds the value. +enum Attr { + Int(i64), + Float(f64), + Str(Box<[u16]>), + Bin(Box<[u8]>), +} + +/// The host's `IAttributeList`: what a plugin puts in an `IMessage`. +#[derive(Default)] +pub(crate) struct HostAttributes { + values: Mutex, Attr>>, +} + +impl Class for HostAttributes { + type Interfaces = (sv::IAttributeList,); +} + +/// An attribute id's bytes (`None` for a null id). +fn attr_key(id: sv::IAttributeList_::AttrID) -> Option> { + // SAFETY: a non-null AttrID is a NUL-terminated C string owned by the caller. + (!id.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(id) }.to_bytes().to_vec()) +} + +impl HostAttributes { + fn values(&self) -> std::sync::MutexGuard<'_, std::collections::HashMap, Attr>> { + self.values.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn set(&self, id: sv::IAttributeList_::AttrID, v: Attr) -> sb::tresult { + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + self.values().insert(k, v); + sb::kResultOk + } +} + +impl sv::IAttributeListTrait for HostAttributes { + unsafe fn setInt(&self, id: sv::IAttributeList_::AttrID, value: sb::int64) -> sb::tresult { + self.set(id, Attr::Int(value)) + } + + unsafe fn getInt(&self, id: sv::IAttributeList_::AttrID, value: *mut sb::int64) -> sb::tresult { + // SAFETY: `value` is the caller's out-pointer (or null, handled). + match ( + attr_key(id).and_then(|k| match self.values().get(&k) { + Some(Attr::Int(v)) => Some(*v), + _ => None, + }), + unsafe { value.as_mut() }, + ) { + (Some(v), Some(out)) => { + *out = v; + sb::kResultOk + } + _ => sb::kResultFalse, + } + } + + unsafe fn setFloat(&self, id: sv::IAttributeList_::AttrID, value: f64) -> sb::tresult { + self.set(id, Attr::Float(value)) + } + + unsafe fn getFloat(&self, id: sv::IAttributeList_::AttrID, value: *mut f64) -> sb::tresult { + // SAFETY: `value` is the caller's out-pointer (or null, handled). + match ( + attr_key(id).and_then(|k| match self.values().get(&k) { + Some(Attr::Float(v)) => Some(*v), + _ => None, + }), + unsafe { value.as_mut() }, + ) { + (Some(v), Some(out)) => { + *out = v; + sb::kResultOk + } + _ => sb::kResultFalse, + } + } + + unsafe fn setString(&self, id: sv::IAttributeList_::AttrID, string: *const sv::TChar) -> sb::tresult { + if string.is_null() { + return sb::kInvalidArgument; + } + let mut s = Vec::new(); + // SAFETY: a non-null string is NUL-terminated UTF-16 owned by the caller; reading stops at + // the NUL or at ATTR_STRING_MAX units. + while s.len() < ATTR_STRING_MAX { + let c = unsafe { *string.add(s.len()) }; + if c == 0 { + break; + } + s.push(c); + } + self.set(id, Attr::Str(s.into_boxed_slice())) + } + + unsafe fn getString(&self, id: sv::IAttributeList_::AttrID, string: *mut sv::TChar, size_in_bytes: sb::uint32) -> sb::tresult { + let units = size_in_bytes as usize / 2; + if string.is_null() || units == 0 { + return sb::kInvalidArgument; + } + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + let values = self.values(); + let Some(Attr::Str(s)) = values.get(&k) else { return sb::kResultFalse }; + let n = s.len().min(units - 1); + // SAFETY: the caller's buffer holds `units` UTF-16 units; `n + 1 <= units` are written. + unsafe { + std::ptr::copy_nonoverlapping(s.as_ptr(), string, n); + *string.add(n) = 0; + } + sb::kResultOk + } + + unsafe fn setBinary(&self, id: sv::IAttributeList_::AttrID, data: *const c_void, size_in_bytes: sb::uint32) -> sb::tresult { + let n = size_in_bytes as usize; + if n > ATTR_BINARY_MAX { + return sb::kOutOfMemory; + } + let bytes: Box<[u8]> = if n == 0 || data.is_null() { + Box::new([]) + } else { + // SAFETY: the caller's `data` holds `n` readable bytes. + unsafe { std::slice::from_raw_parts(data.cast::(), n) }.into() + }; + self.set(id, Attr::Bin(bytes)) + } + + unsafe fn getBinary(&self, id: sv::IAttributeList_::AttrID, data: *mut *const c_void, size_in_bytes: *mut sb::uint32) -> sb::tresult { + let Some(k) = attr_key(id) else { return sb::kInvalidArgument }; + let values = self.values(); + let Some(Attr::Bin(b)) = values.get(&k) else { return sb::kResultFalse }; + // SAFETY: both are the caller's out-pointers (or null, handled). The pointer handed out + // stays valid until this attribute is replaced or the list is released (VST3 contract). + match (unsafe { data.as_mut() }, unsafe { size_in_bytes.as_mut() }) { + (Some(d), Some(sz)) => { + *d = b.as_ptr().cast::(); + *sz = u32::try_from(b.len()).unwrap_or(u32::MAX); + sb::kResultOk + } + _ => sb::kInvalidArgument, + } + } +} + +/// The host's `IMessage`: an id and an attribute list, created for a plugin through +/// `IHostApplication::createInstance`. +pub(crate) struct HostMessage { + id: Mutex, + attributes: ComWrapper, +} + +impl Default for HostMessage { + fn default() -> Self { + HostMessage { id: Mutex::new(std::ffi::CString::default()), attributes: ComWrapper::new(HostAttributes::default()) } + } +} + +impl Class for HostMessage { + type Interfaces = (sv::IMessage,); +} + +impl sv::IMessageTrait for HostMessage { + unsafe fn getMessageID(&self) -> sb::FIDString { + // The string's buffer lives until the next `setMessageID`, as the interface expects. + self.id.lock().unwrap_or_else(PoisonError::into_inner).as_ptr() + } + + unsafe fn setMessageID(&self, id: sb::FIDString) { + // SAFETY: a non-null id is a NUL-terminated C string owned by the caller. + let s = if id.is_null() { std::ffi::CString::default() } else { unsafe { std::ffi::CStr::from_ptr(id) }.to_owned() }; + *self.id.lock().unwrap_or_else(PoisonError::into_inner) = s; + } + + unsafe fn getAttributes(&self) -> *mut sv::IAttributeList { + // Not add-ref'ed: the list belongs to the message (VST3 contract). + self.attributes.as_com_ref::().map_or(std::ptr::null_mut(), |r| r.as_ptr()) + } +} + /// One parameter's points for one block. SoundCraft sends at most one point per block; a /// plugin writing output changes may add more, of which only the last is kept. #[derive(Default)] @@ -2137,6 +2341,57 @@ mod window { #[cfg(test)] mod tests { use super::*; + use sv::{IAttributeListTrait as _, IMessageTrait as _}; + + fn tuid(iid: [u8; 16]) -> sb::TUID { + iid.map(|b| b as c_char) + } + + /// Asks the host for an object the way a plugin does. + fn create(host: &ComWrapper, cid: [u8; 16]) -> Option> { + let app = host.to_com_ptr::()?; + let (mut c, mut i) = (tuid(cid), tuid(I::IID)); + let mut obj: *mut c_void = std::ptr::null_mut(); + // SAFETY: valid TUIDs and out-pointer, as a plugin passes them. + let r = unsafe { app.createInstance(&mut c, &mut i, &mut obj) }; + // SAFETY: on success `obj` is an owned (add-ref'ed) pointer to `I`. + (r == sb::kResultOk).then(|| unsafe { ComPtr::from_raw(obj.cast::()) }).flatten() + } + + #[test] + fn plugins_get_messages_and_attribute_lists_from_the_host() { + let host = ComWrapper::new(HostContext::default()); + let msg = create::(&host, sv::IMessage::IID).expect("IMessage"); + // SAFETY: calls through the interface with valid C strings and buffers. + unsafe { + msg.setMessageID(c"DatabaseInfo".as_ptr()); + assert_eq!(std::ffi::CStr::from_ptr(msg.getMessageID()).to_str(), Ok("DatabaseInfo")); + let attrs = vst3::ComRef::from_raw(msg.getAttributes()).expect("attributes"); + assert_eq!(attrs.setInt(c"count".as_ptr(), 42), sb::kResultOk); + assert_eq!(attrs.setFloat(c"gain".as_ptr(), 0.5), sb::kResultOk); + let text: Vec = "Helix".encode_utf16().chain([0]).collect(); + assert_eq!(attrs.setString(c"name".as_ptr(), text.as_ptr()), sb::kResultOk); + assert_eq!(attrs.setBinary(c"blob".as_ptr(), [1u8, 2, 3].as_ptr().cast(), 3), sb::kResultOk); + + let (mut i, mut f) = (0i64, 0f64); + assert_eq!(attrs.getInt(c"count".as_ptr(), &mut i), sb::kResultOk); + assert_eq!(attrs.getFloat(c"gain".as_ptr(), &mut f), sb::kResultOk); + assert_eq!((i, f), (42, 0.5)); + assert_eq!(attrs.getInt(c"gain".as_ptr(), &mut i), sb::kResultFalse, "a float is not an int"); + assert_eq!(attrs.getInt(c"missing".as_ptr(), &mut i), sb::kResultFalse); + // A buffer of 4 units takes 3 characters and the NUL. + let mut buf = [0xFFFFu16; 4]; + assert_eq!(attrs.getString(c"name".as_ptr(), buf.as_mut_ptr(), 8), sb::kResultOk); + assert_eq!(buf, ['H' as u16, 'e' as u16, 'l' as u16, 0]); + let (mut p, mut n): (*const c_void, u32) = (std::ptr::null(), 0); + assert_eq!(attrs.getBinary(c"blob".as_ptr(), &mut p, &mut n), sb::kResultOk); + assert_eq!(std::slice::from_raw_parts(p.cast::(), n as usize), [1, 2, 3]); + assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused"); + } + assert!(create::(&host, sv::IAttributeList::IID).is_some()); + // Anything else is still not provided. + assert!(create::(&host, sv::IComponentHandler::IID).is_none()); + } #[test] fn the_transport_fills_the_process_context() { diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 9280d921..97532447 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (VST3 host messages: IMessage and IAttributeList; previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) | | 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows | | 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |