mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-31 14:06:56 -06:00
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:
co-authored by
Claude Opus 5.5
parent
c0cb993408
commit
245015fd89
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user