Skip to main content

nsi_ffi_wrap/output/
mod.rs

1#![cfg_attr(feature = "nightly", doc(cfg(feature = "output")))]
2// The double-Box (`Box<Box<dyn Fn…>>`) pattern below is required for
3// crossing the C-FFI boundary: a `Box<dyn Fn…>` is a fat pointer, and the
4// outer Box makes it thin so it fits through `void*`. Clippy doesn't
5// recognise the FFI shape and flags it as `redundant_allocation`. Same for
6// the `new` constructor that returns a `*mut`-shaped state for ndspy to
7// keep -- it's not idiomatic Rust but it IS the C-API contract.
8#![allow(clippy::redundant_allocation)]
9#![allow(clippy::new_ret_no_self)]
10//! Output driver callbacks.
11//!
12//! This module provides type-safe, generic callback support for streaming pixel
13//! data during and after renders. Callbacks are generic over pixel type (`f32`,
14//! `u16`, `u8`, etc.) with zero runtime cost via monomorphization.
15//!
16//! ## Callback Types
17//!
18//! * [`FnOpen`] -- Called once when the output driver is opened by the renderer.
19//!
20//! * [`FnWrite`] -- Called for each bucket of pixel data. Generic over pixel
21//!   type `T: PixelType`. Receives only the bucket data, not the full image.
22//!
23//! * [`FnFinish`] -- Called once when the output driver is closed. Does NOT
24//!   receive pixel data (matching the C ndspy API). Use [`AccumulatingCallbacks`]
25//!   if you need the full accumulated image.
26//!
27//! ## Pixel Types
28//!
29//! The callbacks are generic over pixel type via the [`PixelType`] trait.
30//! Use the corresponding typed driver constant:
31//!
32//! * [`FERRIS_F32`] -- 32-bit float pixels.
33//! * [`FERRIS_U32`]/[`FERRIS_I32`] -- 32-bit integer pixels.
34//! * [`FERRIS_U16`]/[`FERRIS_I16`] -- 16-bit integer pixels.
35//! * [`FERRIS_U8`]/[`FERRIS_I8`] -- 8-bit integer pixels.
36//!
37//! ## Example: Streaming buckets
38//!
39//! ```ignore
40//! use nsi_ffi_wrap as nsi;
41//! use std::sync::{Arc, Mutex};
42//!
43//! let pixels = Arc::new(Mutex::new(Vec::<f32>::new()));
44//! let pixels_write = Arc::clone(&pixels);
45//!
46//! // Write callback receives each bucket as it's rendered.
47//! let write = nsi::output::WriteCallback::<f32>::new(
48//!     move |_name, width, _height, x0, x1, y0, y1, fmt, bucket: &[f32]| {
49//!         let mut buf = pixels_write.lock().unwrap();
50//!         // Accumulate bucket into full image buffer...
51//!         nsi::output::Error::None
52//!     },
53//! );
54//!
55//! ctx.set_attribute(
56//!     "driver",
57//!     &[
58//!         nsi::string!("drivername", nsi::output::FERRIS_F32),
59//!         nsi::string!("imagefilename", "render"),
60//!         nsi::callback!("callback.write", write),
61//!     ],
62//! );
63//! ```
64//!
65//! ## Example: Using AccumulatingCallbacks
66//!
67//! For the common case where you need the full accumulated image at the end,
68//! use [`AccumulatingCallbacks`]:
69//!
70//! ```ignore
71//! use nsi_ffi_wrap as nsi;
72//!
73//! let (write, finish) = nsi::output::AccumulatingCallbacks::<f32>::new(
74//!     |name, width, height, fmt, pixels: Vec<f32>| {
75//!         // Called once with the complete accumulated image.
76//!         write_exr(&name, width, height, &pixels);
77//!         nsi::output::Error::None
78//!     },
79//! );
80//!
81//! ctx.set_attribute(
82//!     "driver",
83//!     &[
84//!         nsi::string!("drivername", nsi::output::FERRIS_F32),
85//!         nsi::string!("imagefilename", "render"),
86//!         nsi::callback!("callback.write", write),
87//!         nsi::callback!("callback.finish", finish),
88//!     ],
89//! );
90//! ```
91//!
92//! ## Color Profiles
93//!
94//! The pixel color data that the renderer generates is linear and
95//! scene-referred. I.e. relative to whatever units you used to describe
96//! illuminants in your scene.
97//!
98//! Using the
99//! [`"colorprofile"` attribute](https://nsi.readthedocs.io/en/latest/nodes.html?highlight=outputlayer#the-outputlayer-node)
100//! of an [`OutputLayer`](crate::OUTPUT_LAYER) you can ask the
101//! renderer to apply an [Open Color IO](https://opencolorio.org/) (OCIO)
102//! [profile/LUT](https://github.com/colour-science/OpenColorIO-Configs/tree/feature/aces-1.2-config/aces_1.2/luts)
103//! before quantizing (see below).
104//!
105//! Once OCIO has a [Rust wrapper](https://crates.io/crates/opencolorio) you can easily choose to
106//! do these color conversions yourself. In the meantime there is the
107//! [`colorspace`](https://crates.io/crates/colorspace) crate which has some useful profiles built
108//! in, e.g. [ACEScg](https://en.wikipedia.org/wiki/Academy_Color_Encoding_System#ACEScg).
109//!
110//! ```
111//! # use nsi_ffi_wrap as nsi;
112//! # let ctx = nsi::Context::new(None).unwrap();
113//! ctx.create("beauty", nsi::OUTPUT_LAYER, None);
114//! ctx.set_attribute(
115//!     "beauty",
116//!     &[
117//!         // The Ci variable comes from Open Shading Language.
118//!         nsi::string!("variablename", "Ci"),
119//!         // We want the pixel data 'display-referred' in sRGB and quantized down to 0.0..255.0.
120//!         nsi::string!("colorprofile", "/home/luts/linear_to_sRGB.spi1d"),
121//!         nsi::string!("scalarformat", "uint8"),
122//!     ],
123//! );
124//! ```
125//!
126//! ## Quantization
127//!
128//! Using the [`"scalarformat"`
129//! attribute](https://nsi.readthedocs.io/en/latest/nodes.html?highlight=outputlayer#the-outputlayer-node)
130//! of an [`OutputLayer`](crate::OUTPUT_LAYER) you can ask the
131//! renderer to quantize data down to a suitable range. For example, setting
132//! this to `"uint16"` will get you valid `u16` values from `0.0..65535.0`, but
133//! stored in the `f32`s of the `pixel_data` buffer. The value of `1.0` will map
134//! to `65535.0` and everything above will be clipped. You can convert
135//! such a value straight via `f32 as u16`.
136//!
137//! Unless you asked the renderer to also apply some color profile (see above)
138//! the data is linear. To look good on a screen it needs to be made
139//! display-referred.
140//!
141//! See the `output` example on how to do this with a simple, display-referred
142//! `sRGB` curve.
143use crate::argument::CallbackPtr;
144use parking_lot::Mutex;
145use std::{
146    ffi::CStr,
147    mem::{size_of, take},
148    os::raw::{c_char, c_int, c_void},
149    sync::Arc,
150};
151
152pub mod pixel_format;
153pub use pixel_format::*;
154
155pub mod pixel_type;
156pub use pixel_type::*;
157
158/// Driver name for f32 pixel type.
159pub static FERRIS_F32: &str = "ferris_f32";
160/// Driver name for u32 pixel type.
161pub static FERRIS_U32: &str = "ferris_u32";
162/// Driver name for i32 pixel type.
163pub static FERRIS_I32: &str = "ferris_i32";
164/// Driver name for u16 pixel type.
165pub static FERRIS_U16: &str = "ferris_u16";
166/// Driver name for i16 pixel type.
167pub static FERRIS_I16: &str = "ferris_i16";
168/// Driver name for u8 pixel type.
169pub static FERRIS_U8: &str = "ferris_u8";
170/// Driver name for i8 pixel type.
171pub static FERRIS_I8: &str = "ferris_i8";
172
173/// Legacy driver name - defaults to f32.
174/// Deprecated: Use [`FERRIS_F32`], [`FERRIS_U16`], etc. for type-specific drivers.
175#[deprecated(
176    since = "0.9.0",
177    note = "Use FERRIS_F32, FERRIS_U16, etc. for type-specific drivers"
178)]
179pub static FERRIS: &str = "ferris_f32";
180
181/// An error type the callbacks return to communicate with the
182/// renderer.
183#[repr(u32)]
184#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, num_enum::IntoPrimitive)]
185pub enum Error {
186    /// Everything is dandy.
187    None = ndspy_sys::PtDspyError::None as _,
188    /// We ran out of memory.
189    NoMemory = ndspy_sys::PtDspyError::NoMemory as _,
190    /// We do no support this request.
191    Unsupported = ndspy_sys::PtDspyError::Unsupported as _,
192    BadParameters = ndspy_sys::PtDspyError::BadParams as _,
193    NoResource = ndspy_sys::PtDspyError::NoResource as _,
194    /// Something else went wrong.
195    Undefined = ndspy_sys::PtDspyError::Undefined as _,
196    /// Stop the render.
197    Stop = ndspy_sys::PtDspyError::Stop as _,
198}
199
200impl From<Error> for ndspy_sys::PtDspyError {
201    fn from(item: Error) -> ndspy_sys::PtDspyError {
202        match item {
203            Error::None => ndspy_sys::PtDspyError::None,
204            Error::NoMemory => ndspy_sys::PtDspyError::NoMemory,
205            Error::Unsupported => ndspy_sys::PtDspyError::Unsupported,
206            Error::BadParameters => ndspy_sys::PtDspyError::BadParams,
207            Error::NoResource => ndspy_sys::PtDspyError::NoResource,
208            Error::Undefined => ndspy_sys::PtDspyError::Undefined,
209            Error::Stop => ndspy_sys::PtDspyError::Stop,
210        }
211    }
212}
213
214/// A closure which is called once per
215/// [`OutputDriver`](crate::OUTPUT_DRIVER) instance.
216///
217/// It is passed to ɴsɪ via the `"callback.open"` attribute on that node.
218///
219/// The closure is called once, before the renderer starts sending pixels to the
220/// output driver.
221///
222/// # Arguments
223/// The `pixel_format` parameter is an array of strings that details the
224/// composition of the `f32` data that the renderer will send to the [`FnWrite`]
225/// and/or [`FnFinish`] closures.
226///
227/// # Example
228/// ```
229/// # #[cfg(feature = "output")]
230/// # {
231/// # use nsi_ffi_wrap as nsi;
232/// # use nsi::output::PixelFormat;
233/// # let ctx = nsi::Context::new(None).unwrap();
234/// # ctx.create("display_driver", nsi::OUTPUT_DRIVER, None);
235/// let open = nsi::output::OpenCallback::new(
236///     |name: &str,
237///      width: usize,
238///      height: usize,
239///      pixel_format: &nsi::output::PixelFormat| {
240///         println!(
241///             "Resolution: {}×{}\nPixel Format:\n{:?}",
242///             width, height, pixel_format
243///         );
244///         nsi::output::Error::None
245///     },
246/// );
247/// # }
248/// ```
249pub trait FnOpen<'a>: FnMut(
250    // Filename.
251    &str,
252    // Width.
253    usize,
254    // Height.
255    usize,
256    // Pixel format.
257    &PixelFormat,
258) -> Error
259+ 'a {}
260
261#[doc(hidden)]
262impl<'a, T: FnMut(&str, usize, usize, &PixelFormat) -> Error + 'a> FnOpen<'a>
263    for T
264{
265}
266
267// FIXME once trait aliases are in stable.
268/*
269trait FnOpen<'a> = FnMut(
270        // Filename.
271        &str,
272        // Width.
273        usize,
274        // Height.
275        usize,
276        // Pixel format.
277        &PixelFormat,
278    ) -> Error
279    + 'a
280*/
281
282/// A closure which is called for each bucket of pixels the
283/// [`OutputDriver`](crate::OUTPUT_DRIVER) instance sends
284/// during rendering.
285///
286/// The closure receives ONLY the bucket data, not an accumulated image.
287/// Bucket dimensions are: `(x_max_plus_one - x_min) x (y_max_plus_one - y_min)`.
288/// Data layout is row-major with channels interleaved per pixel.
289///
290/// It is passed to ɴsɪ via the `"callback.write"` attribute on that node.
291///
292/// # Type Parameter
293///
294/// `T` is the pixel scalar type (e.g., `f32`, `u16`, `u8`). It must implement
295/// [`PixelType`]. The driver name must match the type (e.g., `FERRIS_F32` for `f32`).
296///
297/// # Example
298/// ```ignore
299/// let write = nsi::output::WriteCallback::<f32>::new(
300///     |name: &str,
301///      width: usize,
302///      height: usize,
303///      x_min: usize,
304///      x_max_plus_one: usize,
305///      y_min: usize,
306///      y_max_plus_one: usize,
307///      pixel_format: &nsi::output::PixelFormat,
308///      bucket_data: &[f32]| {
309///         // bucket_data contains ONLY this bucket, not the full image
310///         // Upload bucket to GPU texture, etc.
311///         nsi::output::Error::None
312///     },
313/// );
314///
315/// ctx.set_attribute(
316///     "driver",
317///     &[
318///         nsi::string!("drivername", nsi::output::FERRIS_F32),
319///         nsi::callback!("callback.write", write),
320///     ],
321/// );
322/// ```
323pub trait FnWrite<'a, T: PixelType>: Fn(
324        // Filename.
325        &str,
326        // Full image width.
327        usize,
328        // Full image height.
329        usize,
330        // Bucket x_min.
331        usize,
332        // Bucket x_max_plus_one.
333        usize,
334        // Bucket y_min.
335        usize,
336        // Bucket y_max_plus_one.
337        usize,
338        // Pixel format.
339        &PixelFormat,
340        // Bucket pixel data (NOT the full image!).
341        &[T],
342    ) -> Error
343    + Sync
344    + 'a {}
345
346#[doc(hidden)]
347impl<'a, T: PixelType, F> FnWrite<'a, T> for F where
348    F: Fn(
349            &str,
350            usize,
351            usize,
352            usize,
353            usize,
354            usize,
355            usize,
356            &PixelFormat,
357            &[T],
358        ) -> Error
359        + Sync
360        + 'a
361{
362}
363
364/// A closure which is called once per
365/// [`OutputDriver`](crate::OUTPUT_DRIVER) instance when rendering completes.
366///
367/// It is passed to ɴsɪ via the `"callback.finish"` attribute on that node.
368///
369/// **Note:** This closure does NOT receive pixel data. This matches the C ndspy
370/// API behavior where the display driver is responsible for accumulating pixels
371/// if needed. Use [`AccumulatingCallbacks`] if you need the complete image.
372///
373/// # Example
374/// ```ignore
375/// let finish = nsi::output::FinishCallback::new(
376///     |name: String,
377///      width: usize,
378///      height: usize,
379///      pixel_format: nsi::output::PixelFormat| {
380///         println!("Rendering complete: {}x{}", width, height);
381///         nsi::output::Error::None
382///     },
383/// );
384///
385/// ctx.set_attribute(
386///     "driver",
387///     &[
388///         nsi::string!("drivername", nsi::output::FERRIS_F32),
389///         nsi::callback!("callback.finish", finish),
390///     ],
391/// );
392/// ```
393pub trait FnFinish<'a>: FnMut(
394    // Filename.
395    String,
396    // Width.
397    usize,
398    // Height.
399    usize,
400    // Pixel format.
401    PixelFormat,
402) -> Error
403+ 'a {}
404
405#[doc(hidden)]
406impl<'a, F> FnFinish<'a> for F where
407    F: FnMut(String, usize, usize, PixelFormat) -> Error + 'a
408{
409}
410
411enum Query {}
412
413trait FnQuery<'a>: FnMut(Query) -> Error + 'a {}
414impl<'a, T: FnMut(Query) -> Error + 'a> FnQuery<'a> for T {}
415
416// FIXME once trait aliases are in stable.
417/*
418pub trait FnQuery<'a> = dyn FnMut(Query) -> Error + 'a;
419*/
420
421/// Wrapper to pass an [`FnOpen`] closure to an
422/// [`OutputDriver`](crate::OUTPUT_DRIVER) node.
423pub struct OpenCallback<'a>(Box<Box<dyn FnOpen<'a>>>);
424
425// A `Box<dyn Trait>` is a fat pointer (data + vtable), which cannot cross
426// the C boundary; one more `Box` makes it thin. That is the whole reason
427// for the double `Box` -- and, correspondingly, for `Box<Box<T>>` being
428// the type `extract_callback` reads back.
429//
430// This used to be a *triple* `Box`, which was compensating for
431// `Callback::as_c_ptr` handing ɴsɪ the pointer where ɴsɪ expects the
432// address of the pointer. The renderer's extra dereference consumed the
433// extra layer. With the marshalling fixed, two layers are correct; see
434// `argument::pointer_marshalling_tests` and `ffi_round_trip_tests`.
435impl<'a> OpenCallback<'a> {
436    pub fn new<F>(fn_open: F) -> Self
437    where
438        F: FnOpen<'a>,
439    {
440        OpenCallback(Box::new(Box::new(fn_open)))
441    }
442}
443
444impl CallbackPtr for OpenCallback<'_> {
445    #[doc(hidden)]
446    fn to_ptr(self) -> *const core::ffi::c_void {
447        Box::into_raw(self.0) as *const _ as _
448    }
449
450    #[doc(hidden)]
451    unsafe fn drop_ptr(ptr: *const core::ffi::c_void) {
452        // The lifetime is erased here; drop glue lives in the vtable, so
453        // reclaiming through any lifetime runs the right destructor.
454        // SAFETY: `ptr` came from `to_ptr` above and is reclaimed once.
455        drop(unsafe { Box::from_raw(ptr as *mut Box<dyn FnOpen<'static>>) });
456    }
457}
458/// Wrapper to pass an [`FnWrite`] closure to an
459/// [`OutputDriver`](crate::OUTPUT_DRIVER) node.
460///
461/// # Type Parameter
462///
463/// `T` is the pixel scalar type. Use the matching driver name:
464/// - `WriteCallback::<f32>` with `FERRIS_F32`
465/// - `WriteCallback::<u16>` with `FERRIS_U16`
466/// - etc.
467pub struct WriteCallback<'a, T: PixelType>(Box<Box<dyn FnWrite<'a, T>>>);
468
469impl<'a, T: PixelType> WriteCallback<'a, T> {
470    pub fn new<F>(fn_write: F) -> Self
471    where
472        F: FnWrite<'a, T>,
473    {
474        WriteCallback(Box::new(Box::new(fn_write)))
475    }
476}
477
478impl<T: PixelType> CallbackPtr for WriteCallback<'_, T> {
479    #[doc(hidden)]
480    fn to_ptr(self) -> *const core::ffi::c_void {
481        Box::into_raw(self.0) as *const _ as _
482    }
483
484    #[doc(hidden)]
485    unsafe fn drop_ptr(ptr: *const core::ffi::c_void) {
486        // SAFETY: `ptr` came from `to_ptr` above and is reclaimed once.
487        drop(unsafe {
488            Box::from_raw(ptr as *mut Box<dyn FnWrite<'static, T>>)
489        });
490    }
491}
492
493/// Wrapper to pass an [`FnFinish`] closure to an
494/// [`OutputDriver`](crate::OUTPUT_DRIVER) node.
495///
496/// **Note:** `FnFinish` does not receive pixel data. If you need the complete
497/// accumulated image, use [`AccumulatingCallbacks`] instead.
498pub struct FinishCallback<'a>(Box<Box<dyn FnFinish<'a>>>);
499
500impl<'a> FinishCallback<'a> {
501    pub fn new<F>(fn_finish: F) -> Self
502    where
503        F: FnFinish<'a>,
504    {
505        FinishCallback(Box::new(Box::new(fn_finish)))
506    }
507}
508
509impl CallbackPtr for FinishCallback<'_> {
510    #[doc(hidden)]
511    fn to_ptr(self) -> *const core::ffi::c_void {
512        Box::into_raw(self.0) as *const _ as _
513    }
514
515    #[doc(hidden)]
516    unsafe fn drop_ptr(ptr: *const core::ffi::c_void) {
517        // SAFETY: `ptr` came from `to_ptr` above and is reclaimed once.
518        drop(unsafe { Box::from_raw(ptr as *mut Box<dyn FnFinish<'static>>) });
519    }
520}
521
522/// Internal data structure passed through FFI as the image handle.
523/// Generic over pixel type T for zero-cost type handling.
524struct DisplayData<'a, T: PixelType> {
525    name: String,
526    width: usize,
527    height: usize,
528    pixel_format: PixelFormat,
529    // NO pixel_data buffer - we pass buckets directly to callbacks
530    // Non-owning: these point into the renderer's memory (see
531    // `extract_callback`). Dropping them would free memory we do not own.
532    fn_write: Option<*mut Box<dyn FnWrite<'a, T>>>,
533    fn_finish: Option<*mut Box<dyn FnFinish<'a>>>,
534    // FIXME: unused atm.
535    #[allow(dead_code)]
536    fn_query: Option<*mut Box<dyn FnQuery<'a>>>,
537    // PhantomData to ensure T is used
538    _phantom: std::marker::PhantomData<T>,
539}
540
541/// Reads a callback pointer back out of the parameters ndspy hands the
542/// driver.
543///
544/// `UserParameter::value` is a `void**`: it addresses the renderer's own
545/// copy of the pointer we passed to ɴsɪ. So we read the pointer *out of*
546/// that cell and return it without taking ownership -- the cell belongs to
547/// the renderer, and `Box::from_raw`-ing it frees memory we never
548/// allocated (`free(): invalid pointer`).
549fn extract_callback<T: ?Sized>(
550    name: &str,
551    type_: u8,
552    len: usize,
553    parameters: &[ndspy_sys::UserParameter],
554) -> Option<*mut Box<T>> {
555    for p in parameters.iter() {
556        // SAFETY: Parameter names come from NSI API and should be valid C strings
557        if p.name.is_null() {
558            continue;
559        }
560        let p_name = match unsafe { CStr::from_ptr(p.name) }.to_str() {
561            Ok(name) => name,
562            Err(_) => continue,
563        };
564
565        if name == p_name
566            && type_ == p.valueType as _
567            && len == p.valueCount as _
568        {
569            if !p.value.is_null() {
570                // SAFETY: `value` addresses the renderer's copy of the
571                // pointer produced by the callback's `to_ptr`, and the
572                // parameter type has been verified above. We copy that
573                // pointer out; ownership stays with whoever allocated it.
574                return Some(unsafe { *(p.value as *const *mut Box<T>) });
575            } else {
576                // Parameter exists but value is missing - exit quietly.
577                break;
578            }
579        }
580    }
581    None
582}
583
584// Generic trampoline function for the FnOpen callback.
585// Each instantiation (image_open::<f32>, image_open::<u16>, etc.) handles a specific pixel type.
586pub(crate) extern "C" fn image_open<T: PixelType>(
587    image_handle_ptr: *mut ndspy_sys::PtDspyImageHandle,
588    _driver_name: *const c_char,
589    output_filename: *const c_char,
590    width: c_int,
591    height: c_int,
592    parameters_count: c_int,
593    parameters: *const ndspy_sys::UserParameter,
594    format_count: c_int,
595    format: *mut ndspy_sys::PtDspyDevFormat,
596    flag_stuff: *mut ndspy_sys::PtFlagStuff,
597) -> ndspy_sys::PtDspyError {
598    // Catch any panics to prevent unwinding into C code.
599    match std::panic::catch_unwind(|| {
600        if (image_handle_ptr.is_null())
601            || (output_filename.is_null())
602            || format.is_null()
603            || (format_count <= 0)
604            || ((parameters_count > 0) && parameters.is_null())
605        {
606            return Error::BadParameters.into();
607        }
608
609        // SAFETY: We only read from the parameters slice, never modify it.
610        let parameters = unsafe {
611            std::slice::from_raw_parts(parameters, parameters_count as _)
612        };
613
614        let mut display_data = Box::new(DisplayData::<T> {
615            name: {
616                // SAFETY: output_filename is checked for null above and comes from NSI C API
617                let c_str = unsafe { CStr::from_ptr(output_filename) };
618                c_str.to_string_lossy().into_owned()
619            },
620            width: width as _,
621            height: height as _,
622            pixel_format: PixelFormat::default(),
623            // NO pixel_data allocation - we pass buckets directly
624            fn_write: extract_callback::<dyn FnWrite<T>>(
625                "callback.write",
626                b'p',
627                1,
628                parameters,
629            ),
630            fn_finish: extract_callback::<dyn FnFinish>(
631                "callback.finish",
632                b'p',
633                1,
634                parameters,
635            ),
636            fn_query: None,
637            _phantom: std::marker::PhantomData,
638        });
639
640        // SAFETY: format is a valid pointer to format_count elements from NSI C API.
641        let format = unsafe {
642            std::slice::from_raw_parts_mut(format, format_count as _)
643        };
644
645        // Set format to the requested pixel type T
646        format.iter_mut().for_each(|f| f.type_ = T::NDSPY_TYPE);
647
648        display_data.pixel_format = PixelFormat::new(format);
649
650        let error = if let Some(fn_open) =
651            extract_callback::<dyn FnOpen>("callback.open", b'p', 1, parameters)
652        {
653            // SAFETY: `fn_open` is the pointer the caller handed to ɴsɪ via
654            // `callback!`; it stays alive for as long as the node holds the
655            // attribute. We borrow it, we do not own it.
656            let fn_open = unsafe { &mut *fn_open };
657            fn_open(
658                &display_data.name,
659                width as _,
660                height as _,
661                &display_data.pixel_format,
662            )
663        } else {
664            Error::None
665        };
666
667        // SAFETY: image_handle_ptr and flag_stuff are valid pointers from NSI C API
668        unsafe {
669            *image_handle_ptr = Box::into_raw(display_data) as _;
670            // Preserve renderer-provided flags, but clear the empty-bucket request.
671            (*flag_stuff).flags &=
672                !(ndspy_sys::PkDspyFlagsWantsEmptyBuckets as i32);
673        }
674
675        error.into()
676    }) {
677        Ok(result) => result,
678        Err(_) => {
679            // If we panicked, return an error to the renderer
680            Error::Undefined.into()
681        }
682    }
683}
684
685// FIXME: this will be used for a FnProgress callback later.
686#[unsafe(no_mangle)]
687pub(crate) extern "C" fn image_query(
688    _image_handle_ptr: ndspy_sys::PtDspyImageHandle,
689    query_type: ndspy_sys::PtDspyQueryType,
690    data_len: c_int,
691    data: *mut c_void,
692) -> ndspy_sys::PtDspyError {
693    // Catch any panics to prevent unwinding into C code.
694    match std::panic::catch_unwind(|| {
695        match query_type {
696        ndspy_sys::PtDspyQueryType::RenderProgress => {
697            if (data_len as usize)
698                < core::mem::size_of::<ndspy_sys::PtDspyRenderProgressFuncPtr>()
699                || data.is_null()
700            {
701                Error::BadParameters
702            } else {
703                // SAFETY: data is a valid pointer to PtDspyRenderProgressFuncPtr
704                // as specified by the query type and validated by data_len check.
705                unsafe {
706                    let func_ptr = data as *mut ndspy_sys::PtDspyRenderProgressFuncPtr;
707                    *func_ptr = Some(image_progress);
708                }
709                Error::None
710            }
711        }
712        ndspy_sys::PtDspyQueryType::Progressive => {
713            if (data_len as usize) < size_of::<ndspy_sys::PtDspyProgressiveInfo>()
714                || data.is_null()
715            {
716                Error::BadParameters
717            } else {
718                // SAFETY: data points to PtDspyProgressiveInfo with validated length.
719                unsafe {
720                    let info = data as *mut ndspy_sys::PtDspyProgressiveInfo;
721                    (*info).acceptProgressive = 1;
722                }
723                Error::None
724            }
725        }
726        ndspy_sys::PtDspyQueryType::Thread => {
727            if (data_len as usize) < size_of::<ndspy_sys::PtDspyThreadInfo>()
728                || data.is_null()
729            {
730                Error::BadParameters
731            } else {
732                // SAFETY: data points to PtDspyThreadInfo with validated length.
733                unsafe {
734                    let info = data as *mut ndspy_sys::PtDspyThreadInfo;
735                    // Allow multithreaded buckets.
736                    (*info).multithread = 1;
737                }
738                Error::None
739            }
740        }
741        ndspy_sys::PtDspyQueryType::Cooked => {
742            if (data_len as usize) < size_of::<ndspy_sys::PtDspyCookedInfo>()
743                || data.is_null()
744            {
745                Error::BadParameters
746            } else {
747                // SAFETY: data points to PtDspyCookedInfo with validated length.
748                unsafe {
749                    let info = data as *mut ndspy_sys::PtDspyCookedInfo;
750                    // Accept filtered pixel data (cooked=1 = PkDspyCQDefault).
751                    (*info).cooked = 1;
752                }
753                Error::None
754            }
755        }
756        // StopQuery asks "should rendering stop?" - return None to continue.
757        ndspy_sys::PtDspyQueryType::Stop => Error::None,
758        ndspy_sys::PtDspyQueryType::Overwrite => {
759            if (data_len as usize) < size_of::<ndspy_sys::PtDspyOverwriteInfo>()
760                || data.is_null()
761            {
762                Error::BadParameters
763            } else {
764                // SAFETY: data points to PtDspyOverwriteInfo with validated length.
765                unsafe {
766                    let info = data as *mut ndspy_sys::PtDspyOverwriteInfo;
767                    // Allow the renderer to overwrite existing files.
768                    (*info).overwrite = 1;
769                }
770                Error::None
771            }
772        }
773        _ => Error::Unsupported,
774    }
775    .into()
776    }) {
777        Ok(result) => result,
778        Err(_) => {
779            // If we panicked, return an error to the renderer
780            Error::Undefined.into()
781        }
782    }
783}
784
785// Generic trampoline function for the FnWrite callback.
786// Passes bucket data directly to callback - NO accumulation, NO memcpy to full buffer.
787pub(crate) extern "C" fn image_write<T: PixelType>(
788    image_handle_ptr: ndspy_sys::PtDspyImageHandle,
789    x_min: c_int,
790    x_max_plus_one: c_int,
791    y_min: c_int,
792    y_max_plus_one: c_int,
793    _entry_size: c_int,
794    pixel_data: *const u8,
795) -> ndspy_sys::PtDspyError {
796    // Catch any panics to prevent unwinding into C code
797    match std::panic::catch_unwind(|| {
798        // SAFETY: image_handle_ptr should be valid as it was created by image_open
799        if image_handle_ptr.is_null() {
800            return Error::BadParameters.into();
801        }
802        // Shared, not exclusive. We answer `multithread = 1`, so the
803        // renderer may be inside this call on another thread; `&mut` here
804        // would be two exclusive references to one `DisplayData`. Every
805        // field read below is just that -- a read.
806        let display_data =
807            unsafe { &*(image_handle_ptr as *const DisplayData<T>) };
808
809        let channels = display_data.pixel_format.channels();
810        let bucket_width = (x_max_plus_one - x_min) as usize;
811        let bucket_height = (y_max_plus_one - y_min) as usize;
812        let bucket_pixel_count = bucket_width * bucket_height * channels;
813
814        // SAFETY: pixel_data comes from the renderer and should be valid
815        if pixel_data.is_null() {
816            return Error::None.into();
817        }
818
819        // Zero-cost slice creation - just pointer reinterpretation, no copy!
820        let bucket_data = unsafe {
821            std::slice::from_raw_parts(
822                pixel_data as *const T,
823                bucket_pixel_count,
824            )
825        };
826
827        // Pass bucket directly to callback - NO memcpy, NO accumulation!
828        if let Some(fn_write) = display_data.fn_write {
829            // Shared, not exclusive: we answer `multithread = 1` to
830            // `PkThreadQuery`, so the renderer may be inside this call on
831            // another thread right now. `FnWrite` is `Fn + Sync` for
832            // exactly that reason -- taking `&mut` here would be two
833            // exclusive references to one closure.
834            // SAFETY: borrowed, renderer-owned; see `extract_callback`.
835            let fn_write = unsafe { &*fn_write };
836            fn_write(
837                &display_data.name,
838                display_data.width,
839                display_data.height,
840                x_min as _,
841                x_max_plus_one as _,
842                y_min as _,
843                y_max_plus_one as _,
844                &display_data.pixel_format,
845                bucket_data, // Just the bucket!
846            )
847        } else {
848            Error::None
849        }
850        .into()
851    }) {
852        Ok(result) => result,
853        Err(_) => {
854            // If we panicked, return an error to the renderer
855            Error::Undefined.into()
856        }
857    }
858}
859
860// Generic trampoline function for the FnFinish callback.
861// FnFinish does NOT receive pixel data - user accumulates if needed.
862pub(crate) extern "C" fn image_close<T: PixelType>(
863    image_handle_ptr: ndspy_sys::PtDspyImageHandle,
864) -> ndspy_sys::PtDspyError {
865    // Catch any panics to prevent unwinding into C code
866    match std::panic::catch_unwind(|| {
867        // SAFETY: image_handle_ptr should be valid as it was created by image_open
868        if image_handle_ptr.is_null() {
869            return Error::BadParameters.into();
870        }
871        let mut display_data =
872            unsafe { Box::from_raw(image_handle_ptr as *mut DisplayData<T>) };
873
874        // FnFinish receives no pixel data - user accumulates if needed
875        let error = if let Some(fn_finish) = display_data.fn_finish {
876            // SAFETY: borrowed, renderer-owned; see `extract_callback`.
877            let fn_finish = unsafe { &mut *fn_finish };
878            fn_finish(
879                std::mem::take(&mut display_data.name),
880                display_data.width,
881                display_data.height,
882                std::mem::take(&mut display_data.pixel_format),
883            )
884        } else {
885            Error::None
886        };
887
888        // The callback fields are non-owning pointers into renderer memory,
889        // so there is nothing to release here. The double-free this block
890        // used to guard against was `extract_callback` claiming ownership
891        // of the renderer's pointer cell; see its documentation.
892
893        error.into()
894    }) {
895        Ok(result) => result,
896        Err(_) => {
897            // If we panicked, return an error to the renderer
898            Error::Undefined.into()
899        }
900    }
901}
902
903#[unsafe(no_mangle)]
904extern "C" fn image_progress(
905    _image_handle_ptr: ndspy_sys::PtDspyImageHandle,
906    _progress: f32,
907) -> ndspy_sys::PtDspyError {
908    // Progress logging disabled to reduce spam
909    Error::None.into()
910}
911
912/// Helper for users who want the complete accumulated image at finish time.
913///
914/// The core API passes buckets directly to callbacks without accumulation.
915/// This helper provides the common use case of receiving the complete image
916/// when rendering finishes.
917///
918/// # Example
919///
920/// ```ignore
921/// use std::sync::{Arc, Mutex};
922///
923/// // Create accumulating callbacks that deliver the full image at finish
924/// let (write, finish) = nsi::output::AccumulatingCallbacks::<f32>::new(
925///     |name, width, height, format, pixels| {
926///         // Called once at end with complete accumulated image
927///         save_image(&name, width, height, &pixels);
928///         nsi::output::Error::None
929///     },
930/// );
931///
932/// ctx.set_attribute(
933///     "driver",
934///     &[
935///         nsi::string!("drivername", nsi::output::FERRIS_F32),
936///         nsi::callback!("callback.write", write),
937///         nsi::callback!("callback.finish", finish),
938///     ],
939/// );
940/// ```
941pub struct AccumulatingCallbacks<T: PixelType> {
942    _phantom: std::marker::PhantomData<T>,
943}
944
945impl<T: PixelType> AccumulatingCallbacks<T> {
946    /// Create a pair of callbacks that accumulate pixels and deliver the
947    /// complete image to the finish callback.
948    ///
949    /// Returns `(WriteCallback, FinishCallback)` where:
950    /// - The write callback accumulates buckets into an internal buffer
951    /// - The finish callback delivers the complete buffer to your closure
952    pub fn new<'a, F>(
953        mut on_finish: F,
954    ) -> (WriteCallback<'a, T>, FinishCallback<'a>)
955    where
956        F: FnMut(String, usize, usize, PixelFormat, Vec<T>) -> Error + 'a,
957    {
958        // Shared state between write and finish callbacks.
959        struct AccumState<T: PixelType> {
960            buffer: Vec<T>,
961            width: usize,
962            height: usize,
963            channels: usize,
964            initialized: bool,
965        }
966
967        let state = Arc::new(Mutex::new(AccumState::<T> {
968            buffer: Vec::new(),
969            width: 0,
970            height: 0,
971            channels: 0,
972            initialized: false,
973        }));
974
975        let write_state = state.clone();
976        let finish_state = state;
977
978        let write = WriteCallback::new(
979            move |_name: &str,
980                  width: usize,
981                  height: usize,
982                  x_min: usize,
983                  x_max_plus_one: usize,
984                  y_min: usize,
985                  y_max_plus_one: usize,
986                  format: &PixelFormat,
987                  bucket_data: &[T]| {
988                let mut state = write_state.lock();
989
990                // Initialize on first bucket
991                if !state.initialized {
992                    state.width = width;
993                    state.height = height;
994                    state.channels = format.channels();
995                    state.buffer =
996                        vec![T::default(); width * height * state.channels];
997                    state.initialized = true;
998                }
999
1000                // Copy bucket into the full buffer
1001                let bucket_width = x_max_plus_one - x_min;
1002                let channels = state.channels;
1003
1004                for y in y_min..y_max_plus_one {
1005                    let src_start = (y - y_min) * bucket_width * channels;
1006                    let dst_start = (y * state.width + x_min) * channels;
1007                    let row_len = bucket_width * channels;
1008
1009                    state.buffer[dst_start..dst_start + row_len]
1010                        .copy_from_slice(
1011                            &bucket_data[src_start..src_start + row_len],
1012                        );
1013                }
1014
1015                Error::None
1016            },
1017        );
1018
1019        let finish = FinishCallback::new(
1020            move |name: String,
1021                  width: usize,
1022                  height: usize,
1023                  format: PixelFormat| {
1024                let mut state = finish_state.lock();
1025                let buffer = take(&mut state.buffer);
1026                // Release the lock before calling the user's callback.
1027                drop(state);
1028
1029                on_finish(name, width, height, format, buffer)
1030            },
1031        );
1032
1033        (write, finish)
1034    }
1035}
1036
1037/// End-to-end shape of a callback's journey through the FFI boundary.
1038///
1039/// The driver trampolines are pure Rust -- they never call out through the
1040/// C API -- so Miri can execute the whole lifecycle, which is what makes
1041/// these a proof rather than a smoke test. Run them with:
1042///
1043/// ```text
1044/// cargo +nightly miri test -p nsi-ffi-wrap --features output --lib -- \
1045///     pointer_marshalling ffi_round_trip
1046/// ```
1047#[cfg(test)]
1048mod ffi_round_trip_tests {
1049    use super::*;
1050    use crate::{Arg, ArgData, Callback, argument::to_c_param_vec};
1051    use nsi_sys::NSIParam;
1052    use std::{
1053        cell::Cell,
1054        ffi::CString,
1055        rc::Rc,
1056        sync::{
1057            Arc,
1058            atomic::{AtomicUsize, Ordering},
1059        },
1060    };
1061
1062    /// One ɴsɪ parameter as it reaches a display driver.
1063    ///
1064    /// Models the two hops between `set_attribute` and `DspyImageOpen`:
1065    ///
1066    /// 1. ɴsɪ copies one `void*` **out of** `NSIParam::data` and keeps its
1067    ///    own copy -- `data` addresses the value, it is not the value.
1068    /// 2. ndspy hands the driver the address of *that copy*, so
1069    ///    `UserParameter::value` is a `void**` into renderer memory.
1070    ///
1071    /// Verified against lib3delight 2.9.208 with a probe driver.
1072    struct RendererParam {
1073        name: CString,
1074        /// The renderer's own copy of the pointer. Boxed so its address is
1075        /// stable and distinct from ours, exactly like renderer memory.
1076        cell: Box<*const c_void>,
1077    }
1078
1079    impl RendererParam {
1080        fn new(name: &str, param: &NSIParam) -> Self {
1081            // SAFETY: `data` addresses one `NSITypePointer` value.
1082            let value = unsafe { *(param.data as *const *const c_void) };
1083            Self {
1084                name: CString::new(name).unwrap(),
1085                cell: Box::new(value),
1086            }
1087        }
1088
1089        fn as_user_parameter(&self) -> ndspy_sys::UserParameter {
1090            ndspy_sys::UserParameter {
1091                name: self.name.as_ptr(),
1092                valueType: b'p' as _,
1093                valueCount: 1,
1094                value: &*self.cell as *const *const c_void as *const c_void,
1095                nbytes: core::mem::size_of::<*const c_void>() as _,
1096            }
1097        }
1098    }
1099
1100    /// Marshals `args` and returns them as the driver would see them.
1101    fn through_the_renderer(args: &[Arg]) -> Vec<RendererParam> {
1102        let (_, _, params) = to_c_param_vec(Some(args));
1103        args.iter()
1104            .zip(params.iter())
1105            .map(|(arg, param)| RendererParam::new(arg.name.as_str(), param))
1106            .collect()
1107    }
1108
1109    #[test]
1110    fn open_callback_survives_the_round_trip() {
1111        let ran = Rc::new(Cell::new(false));
1112        let flag = Rc::clone(&ran);
1113
1114        let args = [Arg::new(
1115            "callback.open",
1116            ArgData::from(Callback::new(OpenCallback::new(
1117                move |_: &str, _: usize, _: usize, _: &PixelFormat| {
1118                    flag.set(true);
1119                    Error::None
1120                },
1121            ))),
1122        )];
1123        let seen = through_the_renderer(&args);
1124        let user_params: Vec<_> =
1125            seen.iter().map(RendererParam::as_user_parameter).collect();
1126
1127        let recovered = extract_callback::<dyn FnOpen>(
1128            "callback.open",
1129            b'p',
1130            1,
1131            &user_params,
1132        )
1133        .expect("the callback must be recoverable from the parameters");
1134
1135        {
1136            // Borrow, exactly as the driver trampolines do. Owning this
1137            // would free the renderer's cell.
1138            // SAFETY: `recovered` is the pointer we handed to ɴsɪ above.
1139            let callback = unsafe { &mut *recovered };
1140            assert_eq!(
1141                Error::None,
1142                callback("beauty", 4, 4, &PixelFormat::default())
1143            );
1144        }
1145        assert!(
1146            ran.get(),
1147            "the closure handed to ɴsɪ must be the one invoked"
1148        );
1149
1150        // The round trip must be lossless: what comes back is the very
1151        // pointer that went in, so its original owner can still reclaim it.
1152        // Miri's leak check is the assertion here.
1153        // SAFETY: nothing else owns this allocation.
1154        drop(unsafe { Box::from_raw(recovered) });
1155    }
1156
1157    /// We answer `PkThreadQuery` with `multithread = 1`, so prove we
1158    /// honour it: drive `image_write` from two threads at once on one
1159    /// handle.
1160    ///
1161    /// `PkThreadQuery` is a 3Delight extension (`ndspy.h:167`) -- standard
1162    /// ndspy has no thread negotiation and the renderer serialises calls
1163    /// into the driver. Opting in is what makes `FnWrite: Fn + Sync`
1164    /// necessary: `image_write` takes a *shared* reference to one closure
1165    /// that two threads may be inside simultaneously. Taking `&mut` there
1166    /// -- which this crate did until this test existed -- is two exclusive
1167    /// references to the same `FnMut`.
1168    ///
1169    /// Run under Miri, whose data-race detector is the actual assertion:
1170    ///
1171    /// ```text
1172    /// cargo +nightly miri test -p nsi-ffi-wrap --features output --lib \
1173    ///     -- concurrent_buckets
1174    /// ```
1175    #[test]
1176    fn concurrent_buckets_are_honoured_not_just_advertised() {
1177        let mut info = ndspy_sys::PtDspyThreadInfo { multithread: 0 };
1178        let error = image_query(
1179            core::ptr::null_mut(),
1180            ndspy_sys::PtDspyQueryType::Thread,
1181            core::mem::size_of::<ndspy_sys::PtDspyThreadInfo>() as _,
1182            &mut info as *mut _ as *mut c_void,
1183        );
1184        assert_eq!(ndspy_sys::PtDspyError::None as u32, error as u32);
1185        assert_eq!(
1186            1, info.multithread,
1187            "we advertise concurrent buckets; the rest of this test is \
1188             what earns the right to"
1189        );
1190
1191        let seen = Arc::new(AtomicUsize::new(0));
1192        let counted = Arc::clone(&seen);
1193
1194        let args = [Arg::new(
1195            "callback.write",
1196            ArgData::from(Callback::new(WriteCallback::<f32>::new(
1197                move |_: &str,
1198                      _: usize,
1199                      _: usize,
1200                      _: usize,
1201                      _: usize,
1202                      _: usize,
1203                      _: usize,
1204                      _: &PixelFormat,
1205                      pixels: &[f32]| {
1206                    assert!(pixels.iter().all(|p| *p == 0.5));
1207                    counted.fetch_add(1, Ordering::SeqCst);
1208                    Error::None
1209                },
1210            ))),
1211        )];
1212        let params = through_the_renderer(&args);
1213        let user_params: Vec<_> = params
1214            .iter()
1215            .map(RendererParam::as_user_parameter)
1216            .collect();
1217
1218        let layer_name = CString::new("beauty.000").unwrap();
1219        let mut format = [ndspy_sys::PtDspyDevFormat {
1220            name: layer_name.as_ptr(),
1221            type_: 0,
1222        }];
1223        let mut flags = ndspy_sys::PtFlagStuff { flags: 0 };
1224        let filename = CString::new("render").unwrap();
1225        let driver = CString::new("ferris_f32").unwrap();
1226        let mut handle: ndspy_sys::PtDspyImageHandle = core::ptr::null_mut();
1227
1228        assert_eq!(
1229            ndspy_sys::PtDspyError::None as u32,
1230            image_open::<f32>(
1231                &mut handle,
1232                driver.as_ptr(),
1233                filename.as_ptr(),
1234                4,
1235                4,
1236                user_params.len() as _,
1237                user_params.as_ptr(),
1238                format.len() as _,
1239                format.as_mut_ptr(),
1240                &mut flags,
1241            ) as u32
1242        );
1243
1244        let channels = PixelFormat::new(&format).channels();
1245        let bucket = vec![0.5f32; 2 * 2 * channels];
1246
1247        // The renderer hands different regions to different threads; the
1248        // handle and the closure behind it are shared. Carried in a
1249        // newtype rather than as a `usize`, which would launder away the
1250        // pointer's provenance and blind Miri to exactly what we are
1251        // asking it to check.
1252        struct Shared(ndspy_sys::PtDspyImageHandle);
1253        // SAFETY: this is the renderer's contract under `multithread = 1`
1254        // -- the handle is used from several threads at once.
1255        unsafe impl Sync for Shared {}
1256        let shared = Shared(handle);
1257
1258        std::thread::scope(|scope| {
1259            for y in 0..2 {
1260                let bucket = &bucket;
1261                let shared = &shared;
1262                scope.spawn(move || {
1263                    let error = image_write::<f32>(
1264                        shared.0,
1265                        0,
1266                        2,
1267                        y * 2,
1268                        y * 2 + 2,
1269                        core::mem::size_of::<f32>() as _,
1270                        bucket.as_ptr() as *const u8,
1271                    );
1272                    assert_eq!(
1273                        ndspy_sys::PtDspyError::None as u32,
1274                        error as u32
1275                    );
1276                });
1277            }
1278        });
1279
1280        assert_eq!(
1281            2,
1282            seen.load(Ordering::SeqCst),
1283            "both concurrent buckets must reach the callback"
1284        );
1285
1286        assert_eq!(
1287            ndspy_sys::PtDspyError::None as u32,
1288            image_close::<f32>(handle) as u32
1289        );
1290        // SAFETY: reclaim the one callback we handed over.
1291        unsafe { WriteCallback::<f32>::drop_ptr(*params[0].cell) };
1292    }
1293
1294    /// The whole driver lifecycle, through the real trampolines: open, a
1295    /// bucket of pixels, then close. Nothing here calls out through the C
1296    /// API, so Miri executes every step and checks the memory it touches.
1297    #[test]
1298    fn the_driver_lifecycle_is_sound_end_to_end() {
1299        let opened = Rc::new(Cell::new(false));
1300        // Not `Rc<Cell<_>>`: `FnWrite` is `Fn + Sync`, because the
1301        // renderer may call the write path concurrently.
1302        let buckets = Arc::new(AtomicUsize::new(0));
1303        let finished = Rc::new(Cell::new(false));
1304
1305        let open_flag = Rc::clone(&opened);
1306        let write_count = Arc::clone(&buckets);
1307        let finish_flag = Rc::clone(&finished);
1308
1309        let args = [
1310            Arg::new(
1311                "callback.open",
1312                ArgData::from(Callback::new(OpenCallback::new(
1313                    move |_: &str, w: usize, h: usize, _: &PixelFormat| {
1314                        assert_eq!((4, 4), (w, h));
1315                        open_flag.set(true);
1316                        Error::None
1317                    },
1318                ))),
1319            ),
1320            Arg::new(
1321                "callback.write",
1322                ArgData::from(Callback::new(WriteCallback::<f32>::new(
1323                    move |_: &str,
1324                          _: usize,
1325                          _: usize,
1326                          x_min: usize,
1327                          x_max: usize,
1328                          y_min: usize,
1329                          y_max: usize,
1330                          format: &PixelFormat,
1331                          pixels: &[f32]| {
1332                        // The bucket must be exactly the region announced,
1333                        // one f32 per channel per pixel.
1334                        assert_eq!(
1335                            (x_max - x_min)
1336                                * (y_max - y_min)
1337                                * format.channels(),
1338                            pixels.len()
1339                        );
1340                        assert!(pixels.iter().all(|p| *p == 0.5));
1341                        write_count.fetch_add(1, Ordering::SeqCst);
1342                        Error::None
1343                    },
1344                ))),
1345            ),
1346            Arg::new(
1347                "callback.finish",
1348                ArgData::from(Callback::new(FinishCallback::new(
1349                    move |_: String, _: usize, _: usize, _: PixelFormat| {
1350                        finish_flag.set(true);
1351                        Error::None
1352                    },
1353                ))),
1354            ),
1355        ];
1356        let seen = through_the_renderer(&args);
1357        let user_params: Vec<_> =
1358            seen.iter().map(RendererParam::as_user_parameter).collect();
1359
1360        // One single-channel layer, named the way ndspy names them.
1361        let layer_name = CString::new("beauty.000").unwrap();
1362        let mut format = [ndspy_sys::PtDspyDevFormat {
1363            name: layer_name.as_ptr(),
1364            type_: 0,
1365        }];
1366        let mut flags = ndspy_sys::PtFlagStuff { flags: 0 };
1367        let filename = CString::new("render").unwrap();
1368        let driver = CString::new("ferris_f32").unwrap();
1369        let mut handle: ndspy_sys::PtDspyImageHandle = core::ptr::null_mut();
1370
1371        let error = image_open::<f32>(
1372            &mut handle,
1373            driver.as_ptr(),
1374            filename.as_ptr(),
1375            4,
1376            4,
1377            user_params.len() as _,
1378            user_params.as_ptr(),
1379            format.len() as _,
1380            format.as_mut_ptr(),
1381            &mut flags,
1382        );
1383        assert_eq!(
1384            ndspy_sys::PtDspyError::None as u32,
1385            error as u32,
1386            "image_open must accept the parameters"
1387        );
1388        assert!(opened.get(), "the open callback must have run");
1389        assert!(!handle.is_null(), "image_open must hand back a handle");
1390
1391        // One 2x2 bucket, sized from the format the driver settled on.
1392        // Getting this wrong is a buffer overread, which is precisely the
1393        // kind of thing running this under Miri is meant to catch.
1394        let channels = PixelFormat::new(&format).channels();
1395        let bucket = vec![0.5f32; 2 * 2 * channels];
1396        let error = image_write::<f32>(
1397            handle,
1398            0,
1399            2,
1400            0,
1401            2,
1402            core::mem::size_of::<f32>() as _,
1403            bucket.as_ptr() as *const u8,
1404        );
1405        assert_eq!(ndspy_sys::PtDspyError::None as u32, error as u32);
1406        assert_eq!(
1407            1,
1408            buckets.load(Ordering::SeqCst),
1409            "the write callback must have run once"
1410        );
1411
1412        let error = image_close::<f32>(handle);
1413        assert_eq!(ndspy_sys::PtDspyError::None as u32, error as u32);
1414        assert!(finished.get(), "the finish callback must have run");
1415
1416        // Reclaim what we handed over -- the context does this in real use.
1417        // Miri's leak check verifies we get all of it back, and that
1418        // `image_close` did not free the renderer's memory on the way.
1419        for param in &seen {
1420            // SAFETY: each cell holds the pointer we produced above, and
1421            // each is reclaimed exactly once.
1422            unsafe {
1423                let ptr = *param.cell;
1424                if param.name.as_c_str() == c"callback.open" {
1425                    OpenCallback::drop_ptr(ptr);
1426                } else if param.name.as_c_str() == c"callback.write" {
1427                    WriteCallback::<f32>::drop_ptr(ptr);
1428                } else {
1429                    FinishCallback::drop_ptr(ptr);
1430                }
1431            }
1432        }
1433    }
1434}