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 <noreply@anthropic.com>
This commit is contained in:
Limestudio90
2026-10-13 14:55:34 +02:00
co-authored by Claude Opus 5.5
parent c0cb993408
commit 245015fd89
3 changed files with 264 additions and 7 deletions
+1
View File
@@ -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)
+261 -6
View File
@@ -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::<sv::IMessage>().map(|p| p.into_raw().cast::<c_void>())
} else if wants(sv::IAttributeList::IID) {
ComWrapper::new(HostAttributes::default()).to_com_ptr::<sv::IAttributeList>().map(|p| p.into_raw().cast::<c_void>())
} 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<std::collections::HashMap<Vec<u8>, 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<Vec<u8>> {
// 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<Vec<u8>, 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::<u8>(), 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::<c_void>();
*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<std::ffi::CString>,
attributes: ComWrapper<HostAttributes>,
}
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::<sv::IAttributeList>().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<I: Interface>(host: &ComWrapper<HostContext>, cid: [u8; 16]) -> Option<ComPtr<I>> {
let app = host.to_com_ptr::<sv::IHostApplication>()?;
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::<I>()) }).flatten()
}
#[test]
fn plugins_get_messages_and_attribute_lists_from_the_host() {
let host = ComWrapper::new(HostContext::default());
let msg = create::<sv::IMessage>(&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<u16> = "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::<u8>(), n as usize), [1, 2, 3]);
assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused");
}
assert!(create::<sv::IAttributeList>(&host, sv::IAttributeList::IID).is_some());
// Anything else is still not provided.
assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());
}
#[test]
fn the_transport_fills_the_process_context() {
+2 -1
View File
@@ -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 |