Skip to main content

truce_core/
wrapper.rs

1//! Helpers shared across format wrappers (CLAP, VST3, VST2, AU, AAX, LV2).
2//!
3//! Each wrapper still owns its format-specific descriptor types and
4//! callback tables; those don't unify cleanly. What unifies is the
5//! "boring" boundary glue: building `CStrings` from `ParamInfo`
6//! fields, picking the default bus layout, and resolving install-time
7//! name overrides.
8//!
9//! Each helper is a single small function so the wrappers stay
10//! greppable - the per-format vtable construction code reads as
11//! "for each param, get cstrings, build descriptor" without inlined
12//! `CString::new(...).unwrap_or_default()` boilerplate.
13//!
14//! Adding a new format wrapper? Reach for these first; only fall back
15//! to direct `CString::new` etc. when the format genuinely needs
16//! something none of the other formats does.
17
18use std::any::type_name;
19use std::ffi::CString;
20use std::panic::{AssertUnwindSafe, catch_unwind};
21
22use truce_params::ParamInfo;
23
24use crate::bus::BusLayout;
25use crate::export::PluginExport;
26
27/// `CStrings` derived from a single `ParamInfo`. All four conversions
28/// follow the same pattern (`unwrap_or_default()` so a `\0` in metadata
29/// degrades to an empty C string instead of panicking the host); pulling
30/// them into one struct keeps the per-format vtable loops uniform.
31pub struct ParamCStrings {
32    pub name: CString,
33    pub short_name: CString,
34    pub unit: CString,
35    pub group: CString,
36}
37
38impl ParamCStrings {
39    /// Build all four `CStrings` for one parameter.
40    #[must_use]
41    pub fn from_info(info: &ParamInfo) -> Self {
42        Self {
43            name: CString::new(info.name).unwrap_or_default(),
44            short_name: CString::new(info.short_name).unwrap_or_default(),
45            unit: CString::new(info.unit.as_str()).unwrap_or_default(),
46            group: CString::new(info.group).unwrap_or_default(),
47        }
48    }
49}
50
51/// `(input_channels, output_channels)` for the plugin's default bus
52/// layout, or `None` when the plugin declares no layouts.
53/// Used by every format's vtable / descriptor to advertise channel
54/// counts at registration time.
55///
56/// **Note for `aumi` (MIDI processor) plugins:** the convention is
57/// `bus_layouts: [BusLayout::new()]`, which has zero input *and* zero
58/// output channels. This helper returns `Some((0, 0))` for that case,
59/// which is correct for AU (the AU shim's `channelCapabilities`
60/// returns `[0, 0]` and the host treats the plugin as MIDI-only) but
61/// **wrong for AAX**, which requires every plugin to advertise at
62/// least stereo audio I/O. AAX maps `(0, 0)` to `(2, 2)` (synthesizing
63/// a stereo passthrough) after this helper returns. Don't push that
64/// remap into this helper; only AAX needs it.
65///
66/// `None` indicates a plugin-author bug: zero-bus plugins must return
67/// `vec![BusLayout::new()]` explicitly. Callers should log a
68/// diagnostic and skip registration (see how each `register_*` entry
69/// point handles this) rather than substitute a silent default that
70/// would misreport channel counts to the host.
71#[must_use]
72pub fn default_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
73    P::bus_layouts()
74        .first()
75        .map(|l| (l.total_input_channels(), l.total_output_channels()))
76}
77
78/// Pick the plugin's first bus layout, or `None` when the plugin
79/// declares no layouts.
80/// Used by wrappers (AAX, VST2) that need to read the layout *before*
81/// host-side bus-config negotiation, where a missing layout would
82/// otherwise produce silently-misreported channel counts.
83///
84/// For `aumi` plugins the returned layout is typically `BusLayout::new()`
85/// (zero in / zero out). AAX synthesizes `(2, 2)` from that case in
86/// `register_aax`; see [`default_io_channels`] for the rationale.
87///
88/// `None` is the same plugin-author-bug indicator as
89/// [`default_io_channels`]: log a diagnostic and skip registration.
90#[must_use]
91pub fn first_bus_layout<P: PluginExport>() -> Option<BusLayout> {
92    P::bus_layouts().into_iter().next()
93}
94
95/// Standard diagnostic emitted by `register_*` when [`first_bus_layout`]
96/// or [`default_io_channels`] returns `None`. Centralised so every
97/// wrapper prints the same actionable message.
98pub fn log_missing_bus_layout<P: PluginExport>(format: &str) {
99    eprintln!(
100        "[truce {format}] {}::bus_layouts() returned an empty list - \
101         plugin will not register. Plugins with no audio I/O (e.g. \
102         aumi MIDI-effects) should return vec![BusLayout::new()] \
103         explicitly.",
104        type_name::<P>(),
105    );
106}
107
108/// Run a `register_*` body under [`std::panic::catch_unwind`].
109///
110/// Format wrappers' `register_*` entry points run during plugin
111/// registration - some from `extern "C" fn init` static
112/// initializers (`.init_array` / `__mod_init_func` / `.CRT$XCU`),
113/// others lazily on the first host query (AAX, to keep the Windows
114/// loader-lock window empty during Pro Tools' scan). A panic that
115/// escapes them crosses an `extern "C"`
116/// boundary and aborts the host process - a `panic = "abort"`
117/// configuration would do the same. Catching the unwind here turns
118/// any panic during registration into a logged diagnostic plus
119/// "host sees no plugin," which is the same outcome a plugin author
120/// would expect from a missing `bus_layouts` declaration.
121///
122/// `AssertUnwindSafe` is applied internally - the panic is treated
123/// as fatal-for-this-plugin, so leaving an `Arc` ref-count or
124/// `OnceLock` half-set is acceptable: the host won't load the
125/// plugin and the process will exit shortly after registration
126/// finishes anyway.
127pub fn run_register<P>(format: &str, body: impl FnOnce()) {
128    let result = catch_unwind(AssertUnwindSafe(body));
129    if let Err(payload) = result {
130        eprintln!(
131            "[truce {format}] panic during register for {}: {}",
132            type_name::<P>(),
133            extract_panic_msg(&payload),
134        );
135    }
136}
137
138/// Run a per-block audio-thread `body` under
139/// [`std::panic::catch_unwind`].
140///
141/// Format wrappers call this around the `cb_process` body so a panic
142/// from user `process()` can't unwind across the `extern "C"` FFI
143/// boundary into the host (UB on most toolchains; abort on others).
144/// Returns `true` on clean exit, `false` if the body panicked - the
145/// caller should zero output buffers on `false` so the host doesn't
146/// keep playing whatever happened to be in those slots.
147///
148/// Panic logging is one short `eprintln!` per occurrence; the audio
149/// thread should never panic, so the I/O is rare and acceptable.
150#[must_use]
151pub fn run_audio_block<P>(format: &str, body: impl FnOnce()) -> bool {
152    let result = catch_unwind(AssertUnwindSafe(body));
153    if let Err(payload) = result {
154        eprintln!(
155            "[truce {format}] panic in process() for {}: {}",
156            type_name::<P>(),
157            extract_panic_msg(&payload),
158        );
159        return false;
160    }
161    true
162}
163
164/// Like [`run_audio_block`] but for callbacks that return a status
165/// code. Returns `body`'s value on a clean exit, `fallback` if the
166/// body panicked. Used by the CLAP wrapper, whose process callback
167/// returns a `clap_process_status` `i32`.
168pub fn run_audio_block_with<P, R>(format: &str, fallback: R, body: impl FnOnce() -> R) -> R {
169    match catch_unwind(AssertUnwindSafe(body)) {
170        Ok(r) => r,
171        Err(payload) => {
172            eprintln!(
173                "[truce {format}] panic in process() for {}: {}",
174                type_name::<P>(),
175                extract_panic_msg(&payload),
176            );
177            fallback
178        }
179    }
180}
181
182/// Run a generic `extern "C"` callback body under
183/// [`std::panic::catch_unwind`]. Returns `body`'s value on a clean
184/// exit, `fallback` if the body panicked.
185///
186/// Same shape as [`run_audio_block_with`] but parameterized on
187/// `action` (e.g. `"save_state"`, `"load_state"`) so the panic log
188/// pinpoints which callback boundary fired. Use this for non-process
189/// FFI surfaces - state save / load, param formatting, anything the
190/// host calls through an `extern "C" fn` where a panic would unwind
191/// across an ABI that doesn't promise abort-on-unwind.
192///
193/// Audio-thread process bodies should keep using
194/// [`run_audio_block`] / [`run_audio_block_with`] - the hardcoded
195/// `"process()"` label there keeps existing log lines stable.
196pub fn run_extern_callback_with<P, R>(
197    format: &str,
198    action: &str,
199    fallback: R,
200    body: impl FnOnce() -> R,
201) -> R {
202    match catch_unwind(AssertUnwindSafe(body)) {
203        Ok(r) => r,
204        Err(payload) => {
205            eprintln!(
206                "[truce {format}] panic in {action} for {}: {}",
207                type_name::<P>(),
208                extract_panic_msg(&payload),
209            );
210            fallback
211        }
212    }
213}
214
215fn extract_panic_msg(payload: &Box<dyn std::any::Any + Send>) -> &str {
216    if let Some(s) = payload.downcast_ref::<&'static str>() {
217        s
218    } else if let Some(s) = payload.downcast_ref::<String>() {
219        s.as_str()
220    } else {
221        "<non-string panic payload>"
222    }
223}