Ctrl K
ComunidadGuías…

src/iop/spektrafilm.c

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
    This file is part of darktable,
    Copyright (C) 2026 darktable developers.

    darktable is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    darktable is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with darktable.  If not, see <http://www.gnu.org/licenses/>.
*/

/* spektrafilm — native spectral film simulation.
 *
 * Film modeling powered by spektrafilm (https://github.com/andreavolpato/spektrafilm),
 * GPLv3, © Andrea Volpato. Film/paper profile data CC BY-SA 4.0.
 *
 * This module computes the full spektrafilm colour pipeline natively per pixel:
 *
 *   scene-linear work RGB
 *     -> CAT16 to the film's reference illuminant, xy -> spectral upsampling
 *        (hanatos2025 tc LUT) x film sensitivity            = camera exposure
 *     -> highlight boost / diffusion / halation             (linear, spatial)
 *     -> log exposure -> DIR coupler correction (blurred)   = film development
 *     -> CMY film density -> grain                          (density, spatial)
 *     -> enlarger (dichroic-filtered light through the negative,
 *        print paper sensitivity, midgray-balanced)         = print exposure
 *     -> print diffusion filter (optional)        (linear print exposure, spatial)
 *     -> print density curves (with optional contrast morph)
 *     -> viewing illuminant through the print, CMFs -> XYZ
 *        -> CAT02 -> work RGB -> OkLCh gamut compression    = scanning
 *
 * The per-pixel colour science lives in spektra_sim.c (a validated port of
 * spektrafilm 0.3.x, max deviation < 1e-4 vs the Python reference); the
 * spatial effects (grain / halation / diffusion / highlight boost) live in
 * spektra_core.h/.c, both shared with the OpenCL-side ports.
 *
 * Spectral data ships separately, from
 * https://github.com/darktable-org/darktable-spektrafilm: that repository holds
 * the data packs, the manifest the download button reads, and the exporter
 * (tools/spektrafilm_export_data.py) that turns a spektrafilm release into a
 * pack. The module reads a pack from one of two places, the first winning:
 *   <user data>/darktable/spektrafilm/            (installed by hand)
 *   <user data>/darktable/spektrafilm/packs/<hash>/ (downloaded, one per table)
 * Either directory holds pack.json + spectra_lut.f32 and a profiles/
 * subdirectory of film and paper *.json profiles.
 *
 * This is a scene-to-display view transform: enable it INSTEAD of
 * sigmoid / filmic / agx.
 *
 * Both CPU (process, OpenMP) and GPU (process_cl, data/kernels/spektrafilm.cl)
 * paths exist. The GPU kernels were validated against the CPU engine with
 * POCL to ~1e-6.
 */

#include "bauhaus/bauhaus.h"
#include "common/darktable.h"
#include "common/file_location.h"
#include "control/conf.h"
#include "control/control.h"
#include "develop/imageop.h"
#include "develop/pixelpipe_cache.h"
#include "develop/tiling.h"
#include "develop/imageop_gui.h"
#include "develop/imageop_math.h"
#include "common/imagebuf.h"
#include "common/iop_profile.h"
#include "common/spektra_fetch.h"
#include "common/opencl.h"
#include "common/gaussian.h"
#include "gui/accelerators.h"
#include "dtgtk/button.h"
#include "dtgtk/expander.h"
#include "dtgtk/paint.h"
#include "gui/color_picker_proxy.h"
#include "gui/gtk.h"
#include "gui/presets.h"
#include "iop/iop_api.h"

#include <glib.h>
#include <glib/gstdio.h>
#include <gtk/gtk.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>

#define GRAIN_INLINE static inline
#include "common/gaussian.h"
#include "common/spektra_core.h"
#include "common/spektra_sim.h"

DT_MODULE_INTROSPECTION(3, dt_iop_spektrafilm_params_t)

/* Spatial-scale constants, micrometres on film unless noted (see the LUT
   module for the full rationale; these are shared with modify_roi_in() and
   tiling_callback() so the halo math stays in sync). */
#define SF_HALATION_FIRST_SIGMA_UM 65.0f
#define SF_HALATION_PSF_SIGMAS 1.7320508f /* sqrt(3) */
/* widest stage-1 scatter component: max(sc_tail)*max(tail_rat) from
   spektra_core.c's sf_halation() = 9.7 * 2.7684 um, rounded up */
#define SF_SCATTER_TAIL_MAX_UM 27.0f
/* Per-channel ceilings the ROI padding above is sized for. The scatter PSF is
   per-film pack data now, and modify_roi_in() runs before the sim exists, so it
   cannot measure the real values -- clamp them to what the padding covers
   instead, exactly as hal_sigma_um is clamped to SF_HALATION_FIRST_SIGMA_UM.
   9.7 * SF_EXPTAIL_R2 = 26.85, which is where the 27.0 above comes from. */
#define SF_SCATTER_CORE_CLAMP_UM 2.2f
#define SF_SCATTER_TAIL_CLAMP_UM 9.7f
/* [gl] GlareParams.roughness / .blur -- the reference exposes these but leaves
   them at these values for every profile, so only the amount gets a slider. */
#define SF_GLARE_ROUGHNESS 0.7f
#define SF_GLARE_BLUR_PX 0.5f
/* the spectral table of the first data pack ever published (0.3.3's
   hanatos2025, irradiance_xy_tc). An edit predating the table field can only
   have been rendered with it, that pack having been the only one in
   existence */
#define SF_FIRST_PUBLISHED_LUT_HASH 0x565f4ec4u
/* and the method it is, which that pack does not declare: pack_format 2 named
   no tables, so its header says only the kind ("irradiance_xy_tc@0.3.3"). */
#define SF_FIRST_PUBLISHED_LUT_NAME "hanatos2025"


#define SF_GRAIN_BLUR_MIN 0.05f
/* Upstream's GrainParams.blur_dye_clouds_um (params_schema.py): a SECOND,
 * per-sub-layer blur applied to the raw particle draw INSIDE the particle
 * sampler itself (layer_particle_model in grain.py), before the main
 * clump blur above ever runs -- sigma = SF_GRAIN_DYE_BLUR_UM *
 * sqrt(od_particle), where od_particle = dmax/npart is that sub-layer's
 * own per-particle optical density. Passed through verbatim (no
 * pixel_um conversion anywhere in the reference's own call chain,
 * despite the "_um" name) -- ported as literally as upstream computes it,
 * naming included. No variance restoration afterward either, same as the main
 * clump blur. */
#define SF_GRAIN_DYE_BLUR_UM 2.0f
/* Push/pull processing is really two things happening together: shooting
 * at an effective ISO different from box speed (already modeled via
 * exposure_ev), plus extended/reduced development time, which increases
 * or decreases contrast -- the gamma knob. There's no single fixed
 * physical constant for how much contrast one stop of push buys (it
 * depends on the specific film/developer combination, which isn't
 * modeled here), so this is a documented approximation: each stop
 * multiplies gamma by this factor, a commonly-cited rule of thumb
 * (roughly a 15% contrast increase per stop). Compounds naturally across
 * multiple stops (push 2 = factor^2), which suits gamma being a
 * multiplicative quantity in this model to begin with. */
#define SF_PUSH_PULL_GAMMA_PER_STOP 1.15f
#define SF_HALO_SIGMAS 4.0f
/* DIR coupler inhibitor diffusion; spektrafilm params_schema
   dir_couplers.diffusion_size_um default (a plain gaussian in the reference) */

#define SF_NAME_LEN 128
#define SF_PATH_LEN 1024

/* Combobox data values that are NOT positions in g->entries. Both stock
   comboboxes carry a list position as their entry data and resolve it through
   _entry_at(), so any entry that names no stock needs a value outside that
   range -- negative, since positions start at 0.

   Every such entry must set its data explicitly through
   dt_bauhaus_combobox_add_full(). dt_bauhaus_combobox_add() attaches NULL, and
   GPOINTER_TO_INT(NULL) is 0, which is a perfectly valid position: the two
   lists are one list, so a data-less entry in the film combobox resolves to
   whichever paper sorts first, and one in the paper combobox to a film.
   Bauhaus does auto-number NULL data, but only when entry 0 is also data-less,
   which neither combobox satisfies. */
#define SF_COMBO_PAPER_AUTO  (-1) /* follow the film stock's target print */
#define SF_COMBO_SCAN_FILM   (-2) /* no print stage: view the developed film */
#define SF_COMBO_NO_PROFILES (-3) /* placeholder: the pack lists none of this kind */

/* grid size per axis of the table the color model is evaluated on and
   PCHIP-interpolated from */
#define SF_LUT_STEPS 33

/* order must match SF_DIFF_FAMILIES[] in spektra_core.c */
typedef enum dt_iop_spektrafilm_diffusion_family_t
{
  DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST = 0, // $DESCRIPTION: "black pro-mist"
  DT_SPEKTRAFILM_DIFF_GLIMMERGLASS = 1,   // $DESCRIPTION: "glimmerglass"
  DT_SPEKTRAFILM_DIFF_PRO_MIST = 2,       // $DESCRIPTION: "pro-mist"
  DT_SPEKTRAFILM_DIFF_CINEBLOOM = 3,      // $DESCRIPTION: "cinebloom"
} dt_iop_spektrafilm_diffusion_family_t;

typedef struct dt_iop_spektrafilm_params_t
{
  uint32_t film_hash;       // $DEFAULT: 0  (0 = first available filming stock)
  /* Identity of the spectral upsampling table this edit was developed against.
     Upstream revises it often and every revision changes the render, so an edit
     reopened against a different one is reported. 0 means
     "not recorded" (an edit older than this field, or one
     made while no pack was loaded) and never warns. Diagnostic only -- nothing
     downstream reads it. */
  uint32_t lut_hash;        // $DEFAULT: 0
  uint32_t paper_hash;      // $DEFAULT: 0  (0 = the film's target print stock)
  float exposure_ev;        // $MIN: -12.0 $MAX: 12.0 $DEFAULT: 0.0 $DESCRIPTION: "film exposure"
  /* An offset either way, which is what "compensation" in the name records:
     with auto print exposure off it is the enlarger exposure itself, and with
     it on it shifts the automatic result rather than replacing it. */
  float print_exposure_ev;  // $MIN: -3.0 $MAX: 3.0 $DEFAULT: 0.0 $DESCRIPTION: "print exposure compensation"
  gboolean print_auto_exposure; // $DEFAULT: FALSE $DESCRIPTION: "auto print exposure"
  /* the reference's gamma_factor for the print curves; the field name predates
     the label and is kept, being what presets and styles refer to */
  float print_contrast;     // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma"
  float filter_m;           // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration M"
  float filter_y;           // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "filtration Y"
  float couplers_amount;    // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "DIR couplers"
  /* Inhibitor spread, in micrometres on the film. Absolute, not a scale
     on the stock's value, so the number means the same thing whatever film is
     loaded and survives a film change. Ranges follow the reference's GUI
     manifest (minimum 0 throughout, tail weight up to 1), and the defaults are
     its DirCouplersParams; _sync_coupler_diffusion() moves the sliders' reset
     targets onto whatever the loaded stock ships. */
  float couplers_diffusion_um; // $MIN: 0.0 $MAX: 60.0 $DEFAULT: 20.0 $DESCRIPTION: "inhibitor spread"
  float couplers_tail_um;      // $MIN: 0.0 $MAX: 400.0 $DEFAULT: 200.0 $DESCRIPTION: "spread tail"
  float couplers_tail_weight;  // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.03 $DESCRIPTION: "tail weight"
  float couplers_inhibition_same;  // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 1.0 $DESCRIPTION: "same-layer inhibition"
  float couplers_inhibition_inter; // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 1.0 $DESCRIPTION: "interlayer inhibition"
  float preflash_exposure;  // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash exposure"
  float preflash_m_shift;   // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash M filter shift"
  float preflash_y_shift;   // $MIN: -60.0 $MAX: 60.0 $DEFAULT: 0.0 $DESCRIPTION: "preflash Y filter shift"
  gboolean scan_film;       // $DEFAULT: FALSE $DESCRIPTION: "scan the film (skip print)"
  gboolean halation_on;     // $DEFAULT: TRUE $DESCRIPTION: "enable halation and scatter"
  float scatter_amount;     // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter amount"
  float scatter_scale;      // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "scatter size"
  float halation_amount;    // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "halation strength"
  float halation_scale;     // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "halation size"
  float boost_ev;           // $MIN: 0.0 $MAX: 10.0 $DEFAULT: 0.0 $DESCRIPTION: "highlight boost"
  float boost_range;        // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.3 $DESCRIPTION: "boost range"
  float protect_ev;         // $MIN: 0.0 $MAX: 6.0 $DEFAULT: 4.0 $DESCRIPTION: "boost protect"
  gboolean diffusion_on;    // $DEFAULT: FALSE $DESCRIPTION: "enable diffusion filter"
  dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "diffusion filter type"
  float diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "diffusion strength"
  float diffusion_scale;    // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "diffusion size"
  float diffusion_warmth;   // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "diffusion halo warmth"
  gboolean print_diffusion_on;    // $DEFAULT: FALSE $DESCRIPTION: "enable print diffusion"
  dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family; // $DEFAULT: DT_SPEKTRAFILM_DIFF_BLACK_PRO_MIST $DESCRIPTION: "print diffusion filter type"
  float print_diffusion_strength; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.5 $DESCRIPTION: "print diffusion strength"
  float print_diffusion_scale;    // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "print diffusion size"
  float print_diffusion_warmth;   // $MIN: -1.5 $MAX: 1.5 $DEFAULT: 0.0 $DESCRIPTION: "print diffusion halo warmth"
  gboolean grain_on;        // $DEFAULT: TRUE $DESCRIPTION: "enable grain"
  float grain_amount;       // $MIN: 0.0 $MAX: 8.0 $DEFAULT: 1.0 $DESCRIPTION: "grain strength"
  /* Blur applied to the sampled grain, in pixels (reference: GrainParams.blur).
     Softens grain without touching the particle population, so it makes grain
     less distinct, not coarser; grain_granularity below is what changes
     the crystals. Measured on the output, not on the film, so the right
     value depends on magnification. */
  float grain_blur;         // $MIN: 0.2 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "grain blur"
  /* The reference's GrainParams.rms_granularity, as a scale on the stock's own
     datasheet figure, not an absolute: it is per-channel there (6/8/10
     for the default profile), and a single multiplier keeps a film's measured
     colour balance while moving all three together. Particle area goes as the
     square of it, so this is the control that actually makes grain coarser --
     bigger particles, fewer of them, more fluctuation at every density. */
  float grain_granularity;  // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "granularity"
  /* GrainParams.uniformity, again as a scale. HIGHER bends the noise toward
     the Selwyn bell -- grain that grows and then falls away again with
     density. sf_layer_particle draws against a saturation term 1 - p*unif
     and the variance scales with it, so raising uniformity suppresses the
     fluctuation at high density. The scale reaches the sampler as a plain
     multiplier on the stock's own figure (grain_uniformity_scale in
     spektra_sim.c), so nothing inverts it on the way. At 0.5 the variance
     climbs monotonically to Dmax: no bell. */
  float grain_uniformity;   // $MIN: 0.5 $MAX: 1.03 $DEFAULT: 1.0 $DESCRIPTION: "uniformity"
  /* GrainParams.particle_scale_sublayers, as a scale on every entry but the
     coarsest. Real emulsions layer coarse crystals over fine ones; this moves
     the finer sub-layers relative to the coarsest, which stays the reference
     at 1. */
  float grain_sublayer_scale; // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "sublayer particle scale"
  /* GrainParams.density_min, as a scale on the stock's own measured floor. The
     floor each grain particle sits at, and the reason grain does not vanish
     entirely in clear film. Shared with the enlarger and scan table ranges, so
     it is a property of the emulsion, not of the drawing.

     A scale rather than the reference's absolute value, matching
     grain_granularity and grain_uniformity above: the pack's floors are per
     channel and three of them cannot be spelled with one slider, so an absolute
     control would flatten kodak_vision3_500t's 0.12/0.10/0.35 the moment it was
     touched. Scaling keeps the ratios and still reaches any overall floor. */
  float grain_density_min;  // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "density floor"
  /* GrainParams.blur_dye_clouds_um, as a scale on the reference's own 2 um.
     Each developed crystal produces a small cloud of dye, not a hard
     dot; this is how far that cloud spreads, in real emulsion units, so it only
     becomes visible when a micrometre covers more than a pixel. */
  float grain_dye_cloud;    // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "dye cloud size"
  /* The format combobox above picks a film GAUGE (35mm); this is the frame's
     LONG EDGE (36 mm). Both describe the same format, so the two carry
     different labels -- "format" and "frame long edge" -- to keep the preset
     from reading as a contradiction of the slider beneath it. */
  float film_format_mm;     // $MIN: 5.0 $MAX: 250.0 $DEFAULT: 36.0 $DESCRIPTION: "frame long edge"
  float output_luminance_boost; // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "pre-compression boost"
  /* Gain on the finished colour, after the gamut compressor -- what a tone
     curve moving its white point does, and the other half of the pair with the
     boost above. The boost drives more into the compressor, which lifts the
     picture and desaturates whatever was near the gamut edge; this only changes
     the level and leaves the channel ratios alone. Nothing bounds the result,
     so it can send values past 1.0 for the rest of the pipe to deal with, which
     is why it is the second tool and not the first. */
  float output_scale;           // $MIN: 0.5 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "post-compression scale"
  float grain_usm_sigma;        // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "recovery radius"
  float grain_usm_amount;       // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 1.5 $DESCRIPTION: "recovery strength"
  float film_gamma_factor;      // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "development gamma"
  float film_gamma_factor_fast; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "fast layer gamma"
  float film_gamma_factor_slow; // $MIN: 0.25 $MAX: 4.0 $DEFAULT: 1.0 $DESCRIPTION: "slow layer gamma"
  float film_developer_exhaustion; // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "developer exhaustion"
  float push_pull_stops; // $MIN: -4.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "push/pull"
  float scan_blur;          // $MIN: 0.0 $MAX: 4.0 $DEFAULT: 0.0 $DESCRIPTION: "scanner blur"
  float scan_usm_sigma;     // $MIN: 0.0 $MAX: 3.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen radius"
  float scan_usm_amount;    // $MIN: 0.0 $MAX: 2.0 $DEFAULT: 0.7 $DESCRIPTION: "scanner sharpen strength"
  /* Off by default. Glare is a viewing-condition simulation, not a film
     property, added in the scan as the viewing light: leaving it on lifts the
     black point of every render, which hides the real per-paper black points
     while you are still judging them. The reference defaults it on because it
     renders finished images; this is an editing tool. Note that this is NOT
     the same reasoning as scan_usm_amount, which does default to the
     reference's own 0.7 -- sharpening is part of what a scan looks like,
     whereas glare is a property of the room the print is viewed in. */
  float glare_percent;      // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.0 $DESCRIPTION: "viewing glare"
  float development_min;    // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time"
  float print_development_min; // $MIN: 0.0 $MAX: 15.0 $DEFAULT: 0.0 $DESCRIPTION: "development time"
  /* The two halves of the hanatos2025 sensitivity adaptation, each defaulting
     the way the reference resolves it. Bandwidth on: the filming stage agrees
     with the reference to 4e-15 that way. Surface off: the film profiles carry
     the correction surface and enable it, but the reference runtime's own
     setting disables it and wins, so its renders are made without it. Both are
     left switchable because the coefficients are part of the profile data and
     the upstream defaults may flip; see spektra_sim.h. */
  gboolean adaptation_bandwidth; // $DEFAULT: TRUE $DESCRIPTION: "bandwidth adaptation"
  gboolean adaptation_surface; // $DEFAULT: FALSE $DESCRIPTION: "surface adaptation"
  /* Whether the finished colour goes through the output gamut compressor. The
     reference has the same switch (io.output_gamut_compress.algorithm, which
     takes "off"), so this is the model's own setting and not an addition.

     Note that the compressor is two knees, not one, and this switch gates
     both: a chroma knee towards the output profile's boundary, and a
     lightness knee on OkLab L from 0.7 upwards (SF_OUT_LIGHT_T/_L/_P in
     spektra_sim.c). Switching it off therefore lets the highlights run
     brighter as well as letting saturated colours leave the profile, which
     is worth spelling out wherever this control is described -- the
     brightness half is the one users do not expect from the name.

     Appended at the end of the struct: legacy_params() copies an older prefix
     and leaves the tail at its default, so anything added later goes here. */
  gboolean gamut_compress;     // $DEFAULT: TRUE $DESCRIPTION: "gamut compression"
  /* per-channel multipliers on print_contrast, the reference's own
     gamma_rgb beside its scalar gamma (utils/morph_curves.py). The paper's
     three density curves are morphed independently, so raising one channel
     steepens that dye's response alone: contrast and color cross here, and a
     channel split is how a print is graded for a crossed-over negative, where
     one layer's contrast is wrong rather than all three.

     Multiplicative on print_contrast rather than replacing it, matching how
     the engine combines them (morph_gamma * gamma_ch), so the scalar stays the
     overall control and these three are a trim on top of it */
  float print_gamma_r;         // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma red"
  float print_gamma_g;         // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma green"
  float print_gamma_b;         // $MIN: 0.5 $MAX: 2.0 $DEFAULT: 1.0 $DESCRIPTION: "print gamma blue"
  /* upstream's GrainParams.blur: the clump-blur radius in pixels that
     grain_blur scales, rather than a second control beside it: hence no
     widget. 0.89 is the value study b80 fitted jointly with the multiplicative
     density unsharp mask, whose own halves are already here as
     grain_usm_sigma (0.7) and grain_usm_amount (1.5); the blur softens grain
     and the mask takes the resolution back, so the three only reproduce
     upstream together.

     A parameter and not a constant because it decides how every existing edit
     with grain renders. legacy_params() holds migrated edits at the 0.8 they
     were developed at, which a constant could not do */
  float grain_blur_base;       // $MIN: 0.5 $MAX: 1.5 $DEFAULT: 0.89
  /* which of the pack's spectral upsampling tables this edit renders with, by
     content hash: the same identity lut_hash above records, and the same
     reason: a pack can revise a method's table without renaming it. 0 is the
     pack's default, which is what every edit made before packs carried more
     than one table gets, and the only thing a pack_format 2 pack offers.

     A hash the installed pack does not carry is reported rather than silently
     rendered with another table, exactly as a lut_hash mismatch is */
  uint32_t upsampling_hash;    // $DEFAULT: 0
  /* the pack this edit was developed against. lut_hash above names only the
     spectral table, and a release can carry a table forward byte-identical
     while its profiles move, so two packs answer to one lut_hash and render
     differently. 0 on an edit made before packs carried an identity, and on a
     pack that declares none; both fall back to matching the table */
  uint32_t pack_hash;          // $DEFAULT: 0
  /* black/white point, the reference's scanner.black_correction /
     white_correction and their levels. Fitted for a scan of positive film,
     whose slide carries base density and never reaches D-max, and for a print
     on negative paper, whose range comes from the film behind it; a scan of a
     negative and a print on positive paper carry their own ends and are never
     fitted.

     Off by default, as upstream has them. Choosing a positive stock switches
     them on with scan_film (_film_changed()), which is upstream's
     scan-for-print mode, and legacy_params() gives them the value scan_film
     had: the module fitted positive scans and nothing else before these
     existed */
  gboolean scan_black_correction; // $DEFAULT: FALSE $DESCRIPTION: "black point correction"
  gboolean scan_white_correction; // $DEFAULT: FALSE $DESCRIPTION: "white point correction"
  /* below 0 the fit sends the densest tone under black, where the scan stage
     clips it: shadow detail traded away deliberately, which is what crushing
     a print means and what the reference's own 0 floor cannot express. Above
     about a tenth the densest tone is already a mid gray and the render is
     flat, so the useful span is a fraction of the range; the hard limits stay
     wide and the soft range carries the part worth dragging through */
  float scan_black_level;      // $MIN: -0.1 $MAX: 1.0 $DEFAULT: 0.01 $DESCRIPTION: "black level"
  float scan_white_level;      // $MIN: 0.0 $MAX: 1.0 $DEFAULT: 0.98 $DESCRIPTION: "white level"
} dt_iop_spektrafilm_params_t;

/* one discovered profile: stock (= file base name), display name, stage */
typedef struct sf_prof_entry_t
{
  char stock[SF_NAME_LEN];
  char name[SF_NAME_LEN];
  char target_print[SF_NAME_LEN];
  gboolean printing; /* stage == "printing" */
  gboolean positive; /* info.type == "positive" (slide / reversal) */
  gboolean bw;       /* channel_model == "bw" */
  /* development times this stock is characterised at; n_dev == 0 means a single
     characterisation, i.e. nothing for the development slider to choose */
  int n_dev;
  double dev_times[SF_MAX_DEV_TIMES];
  uint32_t hash;
} sf_prof_entry_t;

typedef struct dt_iop_spektrafilm_gui_data_t
{
  GtkWidget *film, *paper;
  GtkWidget *output_boost, *output_scale;
  GtkWidget *film_format_combo, *film_format_mm_slider;
  GtkWidget *exposure_ev;
  GtkWidget *push_pull_stops, *film_gamma_factor;
  GtkWidget *film_gamma_factor_fast, *film_gamma_factor_slow, *film_developer_exhaustion;
  GtkWidget *adaptation_bandwidth, *adaptation_surface;
  GtkWidget *upsampling;
  GList *tables; /* sf_table_entry_t*, owned; the loaded pack's tables */
  GtkWidget *gamut_compress;
  /* Last status the preview pipe reported, copied out of piece->data at the
     end of commit_params(). The GUI cannot read piece->data itself: it has no
     piece, and the pipes are torn down and rebuilt underneath it. */
  char status_error[256], status_warning[256];
  GtkWidget *print_exposure_ev, *print_auto_exposure, *print_contrast;
  GtkWidget *print_gamma_r, *print_gamma_g, *print_gamma_b;
  GtkWidget *filter_m, *filter_y, *couplers_amount;
  GtkWidget *couplers_diffusion_um, *couplers_tail_um, *couplers_tail_weight;
  GtkWidget *couplers_inhibition_same, *couplers_inhibition_inter;
  GtkWidget *preflash_exposure, *preflash_m_shift, *preflash_y_shift;
  GtkWidget *grain_on, *grain_amount, *grain_blur;
  GtkWidget *grain_granularity, *grain_uniformity, *grain_sublayer_scale;
  GtkWidget *grain_density_min, *grain_dye_cloud;
  GtkWidget *scan_blur, *scan_usm_sigma, *scan_usm_amount, *glare_percent;
  GtkWidget *scan_black_correction, *scan_white_correction;
  GtkWidget *scan_black_level, *scan_white_level;
  GtkWidget *development_min, *print_development_min;
  GtkWidget *grain_usm_sigma, *grain_usm_amount;
  GtkWidget *halation_on, *scatter_amount, *scatter_scale, *halation_amount, *halation_scale;
  GtkWidget *boost_ev, *boost_range, *protect_ev;
  GtkWidget *diffusion_on, *diffusion_filter_family, *diffusion_strength, *diffusion_scale, *diffusion_warmth;
  GtkWidget *print_diffusion_on, *print_diffusion_filter_family;
  GtkWidget *print_diffusion_strength, *print_diffusion_scale, *print_diffusion_warmth;

  /* every profile found on disk, sorted, films and papers together --
     e->printing separates them. Owns its sf_prof_entry_t nodes. */
  GList *entries;
  GtkNotebook *notebook;

  /* data-pack row in the header: a button and a status line, both hidden while
     the installed pack already satisfies the edit. Shown only when there is
     something to do, so the common case stays a clean two-combobox header. */
  /* main_box holds everything except the data-pack row, so the whole module can
     be collapsed to just that row while no usable pack exists. */
  GtkWidget *main_box;
  GtkWidget *data_box, *data_button, *data_status;
  GtkWidget *update_box, *update_button, *update_status, *update_progress;
  gboolean update_fetching; /* the running fetch was started from this row */
  /* why the last fetch started from the update row failed, "" when it did not */
  char update_error[256];
  /* Sections repoint self->widget at their own container while a page is being
     built, so the page box has to be remembered separately. section_container
     is the last one handed out, which is how a fresh page is recognised. */
  GtkWidget *section_page, *section_container;
  GList *sections; /* dt_gui_collapsible_section_t*, owned */
  /* List index of the "auto" entry in the paper combobox, whose label is
     rewritten to name the paper it resolves to. Recorded when the list is
     built: entries ahead of it would otherwise silently shift it. */
  int paper_auto_pos;
  guint data_poll;      /* g_timeout id while a download runs, 0 otherwise */
  /* g_idle id of a posted banner refresh, 0 when none is pending. The source
     closes over the module, so it has to be removable at teardown. Written by
     the pixelpipe thread that posts it and by the GTK thread that runs or
     cancels it, so it lives under the module's GUI critical section. */
  guint trouble_idle;
  uint32_t data_wanted;      /* spectral table the button will ask for */
  uint32_t data_wanted_pack; /* and the pack, which outranks the table */
  gboolean data_checked;  /* a check has run, so "up to date" means something */
  /* sf_fetch_generation() when the GUI last refreshed for an install. A check
     finishes like a download but installs nothing, and must not rebuild the
     module or reprocess the image */
  guint data_gen;
  sf_fetch_state_t data_last_state; /* to spot the moment a fetch finishes */
} dt_iop_spektrafilm_gui_data_t;

static const struct { const char *label; float mm; } _format_presets[] = {
  { "half-frame",  24.0f }, { "35mm",  36.0f }, { "6x6",   56.0f },
  { "6x7",         69.0f }, { "6x9",   84.0f }, { "4x5",  120.0f },
  { "8x10",       244.0f },
  { "Super 8",      5.79f }, { "16mm", 10.26f }, { "Super 16", 12.52f },
  { "Super 35",    24.89f }, { "VistaVision", 37.72f },
  { "65mm 5-perf", 52.63f }, { "IMAX 15-perf", 69.6f },
};
#define FORMAT_PRESETS_N ((int)(sizeof(_format_presets) / sizeof(_format_presets[0])))
#define FORMAT_PRESET_CUSTOM FORMAT_PRESETS_N

static int _format_mm_to_preset(float mm)
{
  for(int i = 0; i < FORMAT_PRESETS_N; i++)
    if(fabsf(_format_presets[i].mm - mm) < 0.01f) return i;
  return FORMAT_PRESET_CUSTOM;
}

static void _format_changed(GtkWidget *combo,
                            gpointer user_data)
{
  dt_iop_module_t *self = (dt_iop_module_t *)user_data;
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  const int pi = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film_format_combo));
  if(pi >= 0 && pi < FORMAT_PRESETS_N)
  {
    DT_ENTER_GUI_UPDATE();
    dt_bauhaus_slider_set(g->film_format_mm_slider, _format_presets[pi].mm);
    DT_LEAVE_GUI_UPDATE();
    p->film_format_mm = _format_presets[pi].mm;
    dt_dev_add_history_item(darktable.develop, self, TRUE);
  }
  /* The millimetre slider only means anything on "custom" -- on a preset it is
     a read-only echo, and one that reads as a contradiction ("35mm" setting
     "36 mm") because the preset names a film GAUGE while the slider is the
     frame's LONG EDGE. Showing it only when it is editable removes both the
     row and the confusion. */
  gtk_widget_set_visible(g->film_format_mm_slider, pi < 0 || pi >= FORMAT_PRESETS_N);
}

static void _populate_format_combo(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_bauhaus_combobox_clear(g->film_format_combo);
  dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "still"));
  for(int i = 0; i < 7; i++)
    dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ),
                                 DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE);
  dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "cine"));
  for(int i = 7; i < FORMAT_PRESETS_N; i++)
    dt_bauhaus_combobox_add_full(g->film_format_combo, _( _format_presets[i].label ),
                                 DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(i), NULL, TRUE);
  dt_bauhaus_combobox_add_section(g->film_format_combo, C_("section", "custom"));
  dt_bauhaus_combobox_add_full(g->film_format_combo, _("custom"),
                               DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT, GINT_TO_POINTER(FORMAT_PRESETS_N), NULL, TRUE);
}

/* per-piece data: parameter snapshot + a lazily (re)built simulation.
   The sim depends on the pipe's work profile, which is only reliably known in
   process(), so the build happens there guarded by a mutex. */
typedef struct dt_iop_spektrafilm_data_t
{
  dt_iop_spektrafilm_params_t p;
  /* engine cache */
  dt_pthread_mutex_t lock;
  sf_sim_t *sim;
  sf_sim_gpu_t *gpu; /* float tables for process_cl */
  uint64_t sim_key;  /* hash of everything the sim build depends on */
  /* the build for sim_key got as far as a loaded pack. With sim NULL it then
     failed on a profile or in the engine, which the same key will fail on
     again, so it is not retried until the key changes. */
  gboolean sim_tried;
  char sim_error[256];
  char sim_warning[256];
  /* Set by commit_params() for the preview pipe only, NULL everywhere else, so
     that exactly one pipe publishes its status to the GUI (see _ensure_sim).
     Letting every pipe write would have them racing over the same buffers for
     no gain -- they all reach the same verdict from the same params. */
  dt_iop_module_t *self;
  /* multi-sublayer grain GPU constant buffers (see process_cl's grain
     stage): built from d->gpu's grain_layer_* tables, which only change
     when d->gpu itself is rebuilt (a new film/paper choice), never
     per-tile. Cached here and keyed on the `gpu` pointer they were built
     from AND on the device they were built on, so they survive across
     process_cl() calls -- tiled processing calls process_cl() once per tile,
     and re-uploading per tile would cost one transfer per tile for data that
     is identical across every tile of the same image.

     The device is part of the key because a cl_mem belongs to the context
     that created it. piece->data lives as long as the pipe, but pipe->devid
     is reassigned by dt_opencl_lock_device() on every run -- that is what the
     device pool is for -- so on a machine with more than one OpenCL device a
     pipe can upload these on one device and, next run, hand them to a kernel
     on another. The handles are still valid, just foreign, and clSetKernelArg
     dereferences them inside the driver: it segfaults there rather than
     returning an error, so there is nothing to check after the fact. */
  const sf_sim_gpu_t *grain_cl_built_for;
  int grain_cl_devid;
#ifdef HAVE_OPENCL
  cl_mem grain_cl_dmax, grain_cl_npart, grain_cl_dmin, grain_cl_total, grain_cl_curve;
#endif
} dt_iop_spektrafilm_data_t;

typedef struct dt_iop_spektrafilm_global_data_t
{
  int kernel_expose, kernel_lograw, kernel_develop_corr, kernel_develop;
  int kernel_grain_gen_raw_sl, kernel_grain_accumulate_1c, kernel_grain_finalize_channel,
      kernel_grain_add, kernel_grain_usm;
  int kernel_print_expose, kernel_print_develop, kernel_scan, kernel_passthrough;
  int kernel_crop_out, kernel_scan_usm, kernel_glare_gen;
  int kernel_scatter_combine, kernel_accum, kernel_channel_extract, kernel_channel_accum, kernel_halation_apply;
  int kernel_gauss_row_4c, kernel_gauss_col_4c, kernel_gauss_row_1c, kernel_gauss_col_1c;
  int kernel_yvv_row_4c, kernel_yvv_col_4c, kernel_yvv_row_1c, kernel_yvv_col_1c;
  int kernel_wide_rows, kernel_wide_down, kernel_wide_up;
  int kernel_boost, kernel_diffusion_accum, kernel_diffusion_mix;
  int kernel_pow10, kernel_log10;
} dt_iop_spektrafilm_global_data_t;

/* the data pack is large (spectra LUT ~12 MB) and shared by all pieces;
   load it once per process (lazily, under _pack_lock), freed in
   cleanup_global(). Kept in module-static storage so every pipe sees the same pack. */
static sf_pack_t *_pack = NULL;
/* Directory _pack was loaded from. Which directory that is depends on the edit:
   an image developed against an older spectral table resolves to a different
   pack than one developed against the current one. Keeping the path lets
   _ensure_sim() notice the resolved directory has changed and reload, and lets
   _scan_profiles() read profiles out of the SAME pack, not always out of
   the pack directory -- profiles and spectral table have to come from one
   place or the film list will not match the data behind it. */
static char _pack_path[SF_PATH_LEN] = { 0 };
/* Value of sf_fetch_generation() when _pack / _pack_error were last decided.
   A download can install a pack into a directory this module already probed
   and rejected, which leaves the resolved path unchanged -- so a path
   comparison alone cannot tell that the answer has changed. */
static guint _pack_gen = 0;
static char _pack_error[256] = { 0 };
static dt_pthread_mutex_t _pack_lock;

/* The last profile scan, kept so that resolving a stock hash does not cost a
   parse of every profile in the pack. _ensure_sim() scans on every rebuild,
   once per pipe, and the scan loads each profile in full to read its header:
   about 67 ms for the 31 profiles of the 0.3.3 pack against roughly 130 ms
   for the build it precedes.

   Valid for one profiles directory at one modification time and one fetch
   generation. The generation covers a download, which can replace a pack in
   place; the directory's mtime covers a hand-installed pack whose files are
   added or removed underneath a running darktable. Callers get their own
   copy of the list and free it as before. */
static GMutex _prof_cache_lock;
static GList *_prof_cache = NULL;
static char _prof_cache_dir[SF_PATH_LEN + 16] = { 0 };
static gint64 _prof_cache_mtime = -1;
static guint _prof_cache_gen = 0;

void init_global(dt_iop_module_so_t *self)
{
  dt_pthread_mutex_init(&_pack_lock);
  sf_fetch_init();

  const int program = 43; /* spektrafilm.cl in data/kernels/programs.conf */
  dt_iop_spektrafilm_global_data_t *gd = malloc(sizeof(dt_iop_spektrafilm_global_data_t));
  self->data = gd;
  if(!gd) return; /* cleanup_global() tolerates a NULL self->data */
  gd->kernel_expose = dt_opencl_create_kernel(program, "spektrafilm_expose");
  gd->kernel_lograw = dt_opencl_create_kernel(program, "spektrafilm_lograw");
  gd->kernel_develop_corr = dt_opencl_create_kernel(program, "spektrafilm_develop_corr");
  gd->kernel_develop = dt_opencl_create_kernel(program, "spektrafilm_develop");
  gd->kernel_grain_gen_raw_sl = dt_opencl_create_kernel(program, "spektrafilm_grain_gen_raw_sl");
  gd->kernel_grain_accumulate_1c = dt_opencl_create_kernel(program, "spektrafilm_grain_accumulate_1c");
  gd->kernel_grain_finalize_channel
      = dt_opencl_create_kernel(program, "spektrafilm_grain_finalize_channel");
  gd->kernel_grain_add = dt_opencl_create_kernel(program, "spektrafilm_grain_add");
  gd->kernel_grain_usm = dt_opencl_create_kernel(program, "spektrafilm_grain_usm");
  gd->kernel_print_expose = dt_opencl_create_kernel(program, "spektrafilm_print_expose");
  gd->kernel_print_develop = dt_opencl_create_kernel(program, "spektrafilm_print_develop");
  gd->kernel_scan = dt_opencl_create_kernel(program, "spektrafilm_scan");
  gd->kernel_crop_out = dt_opencl_create_kernel(program, "spektrafilm_crop_out");
  gd->kernel_scan_usm = dt_opencl_create_kernel(program, "spektrafilm_scan_usm");
  gd->kernel_glare_gen = dt_opencl_create_kernel(program, "spektrafilm_glare_gen");
  gd->kernel_passthrough = dt_opencl_create_kernel(program, "spektrafilm_passthrough");
  gd->kernel_scatter_combine = dt_opencl_create_kernel(program, "spektrafilm_scatter_combine");
  gd->kernel_accum = dt_opencl_create_kernel(program, "spektrafilm_accum");
  gd->kernel_channel_extract = dt_opencl_create_kernel(program, "spektrafilm_channel_extract");
  gd->kernel_yvv_row_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_4c");
  gd->kernel_yvv_col_4c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_4c");
  gd->kernel_yvv_row_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_row_1c");
  gd->kernel_yvv_col_1c = dt_opencl_create_kernel(program, "spektrafilm_yvv_col_1c");
  gd->kernel_wide_rows = dt_opencl_create_kernel(program, "spektrafilm_wide_rows");
  gd->kernel_wide_down = dt_opencl_create_kernel(program, "spektrafilm_wide_down");
  gd->kernel_wide_up = dt_opencl_create_kernel(program, "spektrafilm_wide_up");
  gd->kernel_gauss_row_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_4c");
  gd->kernel_gauss_col_4c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_4c");
  gd->kernel_gauss_row_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_row_1c");
  gd->kernel_gauss_col_1c = dt_opencl_create_kernel(program, "spektrafilm_gauss_col_1c");
  gd->kernel_channel_accum = dt_opencl_create_kernel(program, "spektrafilm_channel_accum");
  gd->kernel_halation_apply = dt_opencl_create_kernel(program, "spektrafilm_halation_apply");
  gd->kernel_boost = dt_opencl_create_kernel(program, "spektrafilm_boost");
  gd->kernel_diffusion_accum = dt_opencl_create_kernel(program, "spektrafilm_diffusion_accum");
  gd->kernel_diffusion_mix = dt_opencl_create_kernel(program, "spektrafilm_diffusion_mix");
  gd->kernel_pow10 = dt_opencl_create_kernel(program, "spektrafilm_pow10");
  gd->kernel_log10 = dt_opencl_create_kernel(program, "spektrafilm_log10");
}

void cleanup_global(dt_iop_module_so_t *self)
{
  dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->data;
  if(gd)
  {
    dt_opencl_free_kernel(gd->kernel_expose);
    dt_opencl_free_kernel(gd->kernel_lograw);
    dt_opencl_free_kernel(gd->kernel_develop_corr);
    dt_opencl_free_kernel(gd->kernel_develop);
    dt_opencl_free_kernel(gd->kernel_grain_gen_raw_sl);
    dt_opencl_free_kernel(gd->kernel_grain_accumulate_1c);
    dt_opencl_free_kernel(gd->kernel_grain_finalize_channel);
    dt_opencl_free_kernel(gd->kernel_grain_add);
    dt_opencl_free_kernel(gd->kernel_grain_usm);
    dt_opencl_free_kernel(gd->kernel_print_expose);
    dt_opencl_free_kernel(gd->kernel_print_develop);
    dt_opencl_free_kernel(gd->kernel_scan);
    dt_opencl_free_kernel(gd->kernel_crop_out);
    dt_opencl_free_kernel(gd->kernel_scan_usm);
    dt_opencl_free_kernel(gd->kernel_glare_gen);
    dt_opencl_free_kernel(gd->kernel_passthrough);
    dt_opencl_free_kernel(gd->kernel_scatter_combine);
    dt_opencl_free_kernel(gd->kernel_accum);
    dt_opencl_free_kernel(gd->kernel_channel_extract);
    dt_opencl_free_kernel(gd->kernel_yvv_row_4c);
    dt_opencl_free_kernel(gd->kernel_yvv_col_4c);
    dt_opencl_free_kernel(gd->kernel_yvv_row_1c);
    dt_opencl_free_kernel(gd->kernel_yvv_col_1c);
    dt_opencl_free_kernel(gd->kernel_wide_rows);
    dt_opencl_free_kernel(gd->kernel_wide_down);
    dt_opencl_free_kernel(gd->kernel_wide_up);
    dt_opencl_free_kernel(gd->kernel_gauss_row_4c);
    dt_opencl_free_kernel(gd->kernel_gauss_col_4c);
    dt_opencl_free_kernel(gd->kernel_gauss_row_1c);
    dt_opencl_free_kernel(gd->kernel_gauss_col_1c);
    dt_opencl_free_kernel(gd->kernel_channel_accum);
    dt_opencl_free_kernel(gd->kernel_halation_apply);
    dt_opencl_free_kernel(gd->kernel_boost);
    dt_opencl_free_kernel(gd->kernel_diffusion_accum);
    dt_opencl_free_kernel(gd->kernel_diffusion_mix);
    dt_opencl_free_kernel(gd->kernel_pow10);
    dt_opencl_free_kernel(gd->kernel_log10);
    free(self->data);
    self->data = NULL;
  }
  /* Before the pack lock goes away: a running fetch reprocesses the pipe on
     completion, so it has to be joined while there is still a pipe to touch. */
  sf_fetch_cleanup();
  dt_pthread_mutex_lock(&_pack_lock);
  if(_pack)
  {
    sf_pack_free(_pack);
    _pack = NULL;
  }
  _pack_path[0] = 0;
  _pack_gen = 0;
  _pack_error[0] = 0;
  dt_pthread_mutex_unlock(&_pack_lock);
  dt_pthread_mutex_destroy(&_pack_lock);
  g_mutex_lock(&_prof_cache_lock);
  g_list_free_full(_prof_cache, g_free);
  _prof_cache = NULL;
  _prof_cache_dir[0] = 0;
  _prof_cache_mtime = -1;
  g_mutex_unlock(&_prof_cache_lock);
}

const char *name(void)
{
  return _("spektrafilm");
}

const char *aliases(void)
{
  return _("film simulation|analog|spectral|grain|halation|print");
}

const char **description(dt_iop_module_t *self)
{
  return dt_iop_set_description(
      self,
      _("simulates the physical process of developing and printing analog film,\n"
        "using spectral emulsion and paper data from the spektrafilm project"),
      _("creative"), _("linear, RGB, scene-referred"), _("non-linear, RGB"),
      _("non-linear, RGB, display-referred"));
}

int default_group(void)
{
  /* Same grouping as the other display transforms (filmicrgb, sigmoid, agx),
     not the grading modules. It matters beyond tidiness: "only use one display
     transform" is the module's first piece of advice, and a group of its own
     would separate it from every module it conflicts with. */
  return IOP_GROUP_TONE | IOP_GROUP_TECHNICAL;
}

int flags(void)
{
  return IOP_FLAGS_SUPPORTS_BLENDING | IOP_FLAGS_INCLUDE_IN_STYLES | IOP_FLAGS_ALLOW_TILING;
}

/* v1 -> v2: grain_density_min changes meaning from an absolute density to a
   scale on the stock's own measured floor. Same type and offset, so the struct
   is unchanged and the copy is a straight one -- only the value has to move,
   from v1's 0.03 default to a neutral 1.0.

   Every v1 edit rendered on the film's own floor whatever that field said:
   sf_pack_film_grain() wrote the pack's value over it before anything read it.
   So 1.0 does not approximate a v1 edit, it reproduces it exactly, and a v1
   slider position carries no information worth carrying forward. */
/* v2 -> v3: quality dropped (every edit renders on the SF_LUT_STEPS table),
   print_gamma_r/_g/_b and the fields after them appended. v1 and v2 share this
   layout, so one struct reads both; the copy below takes the fields either
   side of quality, which v3 keeps in the same order */
typedef struct dt_iop_spektrafilm_params_v2_t
{
  uint32_t film_hash;
  uint32_t lut_hash;
  uint32_t paper_hash;
  float exposure_ev;
  float print_exposure_ev;
  gboolean print_auto_exposure;
  float print_contrast;
  float filter_m;
  float filter_y;
  float couplers_amount;
  float couplers_diffusion_um;
  float couplers_tail_um;
  float couplers_tail_weight;
  float couplers_inhibition_same;
  float couplers_inhibition_inter;
  float preflash_exposure;
  float preflash_m_shift;
  float preflash_y_shift;
  gboolean scan_film;
  int quality;
  gboolean halation_on;
  float scatter_amount;
  float scatter_scale;
  float halation_amount;
  float halation_scale;
  float boost_ev;
  float boost_range;
  float protect_ev;
  gboolean diffusion_on;
  dt_iop_spektrafilm_diffusion_family_t diffusion_filter_family;
  float diffusion_strength;
  float diffusion_scale;
  float diffusion_warmth;
  gboolean print_diffusion_on;
  dt_iop_spektrafilm_diffusion_family_t print_diffusion_filter_family;
  float print_diffusion_strength;
  float print_diffusion_scale;
  float print_diffusion_warmth;
  gboolean grain_on;
  float grain_amount;
  float grain_blur;
  float grain_granularity;
  float grain_uniformity;
  float grain_sublayer_scale;
  float grain_density_min;
  float grain_dye_cloud;
  float film_format_mm;
  float output_luminance_boost;
  float output_scale;
  float grain_usm_sigma;
  float grain_usm_amount;
  float film_gamma_factor;
  float film_gamma_factor_fast;
  float film_gamma_factor_slow;
  float film_developer_exhaustion;
  float push_pull_stops;
  float scan_blur;
  float scan_usm_sigma;
  float scan_usm_amount;
  float glare_percent;
  float development_min;
  float print_development_min;
  gboolean adaptation_bandwidth;
  gboolean adaptation_surface;
  gboolean gamut_compress;
} dt_iop_spektrafilm_params_v2_t;

int legacy_params(dt_iop_module_t *self,
                  const void *const old_params,
                  const int old_version,
                  void **new_params,
                  int32_t *new_params_size,
                  int *new_version)
{
  if(old_version != 1 && old_version != 2) return 1;

  dt_iop_spektrafilm_params_t *n = calloc(1, sizeof(dt_iop_spektrafilm_params_t));
  if(!n) return 1;
  const size_t head = offsetof(dt_iop_spektrafilm_params_v2_t, quality);
  const size_t tail = offsetof(dt_iop_spektrafilm_params_v2_t, halation_on);
  memcpy(n, old_params, head);
  memcpy((char *)n + head, (const char *)old_params + tail,
         sizeof(dt_iop_spektrafilm_params_v2_t) - tail);
  if(old_version == 1) n->grain_density_min = 1.0f;
  n->print_gamma_r = n->print_gamma_g = n->print_gamma_b = 1.0f;
  /* not the 0.89 default: these edits were developed against 0.8 */
  n->grain_blur_base = 0.8f;
  /* the pack's default table, which is the only one these edits ever had */
  n->upsampling_hash = 0u;
  /* named no pack, and could not have: none declared an identity yet */
  n->pack_hash = 0u;
  /* the endpoints the module placed for these edits before they were exposed:
     a positive scan was fitted, a print never was, and a negative scan
     ignores the switches */
  n->scan_black_correction = n->scan_white_correction = n->scan_film;
  n->scan_black_level = 0.01f;
  n->scan_white_level = 0.98f;
  /* an edit of this vintage that recorded no table was made when exactly one
     pack existed, so that is the table it used: there was nothing else to
     render with. Left at 0 it would instead resolve to whatever happens to be
     installed, which on a machine that has since moved to a later pack is a
     different set of profiles and a different render.

     Only here, where the record's own version proves it predates the choice. A
     fresh edit also starts at 0 and must keep resolving to the default pack,
     which is why this cannot be done in the resolver */
  if(!n->lut_hash) n->lut_hash = SF_FIRST_PUBLISHED_LUT_HASH;

  *new_params = n;
  *new_params_size = sizeof(dt_iop_spektrafilm_params_t);
  *new_version = 3;
  return 0;
}


dt_iop_colorspace_type_t default_colorspace(dt_iop_module_t *self,
                                            dt_dev_pixelpipe_t *p,
                                            dt_dev_pixelpipe_iop_t *pi)
{
  return IOP_CS_RGB;
}

/* ---------------------------------------------------------------------- */
/* profile discovery                                                      */
/* ---------------------------------------------------------------------- */

/* Stable identity for a profile in params: the stock name hashed, so a rescan
   or a different machine resolves the same stock regardless of directory order.
   Folded to 32 bits because params store it as uint32_t. */
static uint32_t _name_hash(const char *s)
{
  const dt_hash_t h = dt_hash(DT_INITHASH, s, strlen(s));
  const uint32_t h32 = (uint32_t)(h ^ (h >> 32));
  return h32 ? h32 : 1; /* 0 is reserved for "first available" */
}

/* <user data>/darktable/spektrafilm -- the folder a user can put a pack in by
   hand, safe from the fetcher, which only ever writes to the `packs`
   subdirectory below it (see spektra_fetch.c). A pack here wins over every
   downloaded one, so it is also the answer to "how do I pin a specific
   version", and it is the folder named when no pack can be found at all. */
static void _pack_dir(char *dst,
                      size_t dstsz)
{
  char *dir = g_build_filename(g_get_user_data_dir(), "darktable", "spektrafilm", NULL);
  g_strlcpy(dst, dir, dstsz);
  g_free(dir);
}

/* the installed pack carrying this spectral table, or FALSE. Peeks the same
   declaration the GUI lists from, so a table the user can pick is a table the
   pipeline can find. 0 asks for nothing and always answers FALSE */
static gboolean _dir_has_table(const char *dir,
                               const uint32_t table_hash)
{
  sf_table_info_t info[SF_MAX_TABLES];
  const int n = sf_pack_peek_tables(dir, info, SF_MAX_TABLES);
  for(int i = 0; i < n; i++)
    if(info[i].lut_hash == table_hash) return TRUE;
  return FALSE;
}

static gboolean _pack_dir_with_table(const uint32_t table_hash,
                                     char *dst,
                                     const size_t dstsz)
{
  if(!table_hash) return FALSE;
  gboolean found = FALSE;
  GPtrArray *packs = sf_fetch_list_packs();
  for(guint pi = 0; packs && pi < packs->len && !found; pi++)
  {
    const sf_fetch_pack_t *fp = g_ptr_array_index(packs, pi);
    if(_dir_has_table(fp->dir, table_hash))
    {
      g_strlcpy(dst, fp->dir, dstsz);
      found = TRUE;
    }
  }
  if(packs) g_ptr_array_unref(packs);
  return found;
}

/* the installed pack an edit that names no pack was made with: one carrying its
   table AND declaring no identity of its own.

   An edit predating pack_hash was necessarily made against a pack predating
   pack_hash, that being the only kind that existed. Without this, such an edit
   resolves on its table alone, and a later pack that carries the same table
   forward byte-identical answers to it just as well, while its profiles render
   differently. That is the whole failure pack_hash exists to prevent, and the
   one case pack_hash cannot express, because the pack it wants has none.

   FALSE when no such pack is installed, which leaves the edit to resolve on
   its table as before; there is nothing better available then */
static gboolean _legacy_pack_dir_with_table(const uint32_t table_hash,
                                            char *dst,
                                            const size_t dstsz)
{
  if(!table_hash) return FALSE;
  gboolean found = FALSE;
  GPtrArray *packs = sf_fetch_list_packs();
  for(guint pi = 0; packs && pi < packs->len && !found; pi++)
  {
    const sf_fetch_pack_t *fp = g_ptr_array_index(packs, pi);
    if(sf_fetch_peek_pack_hash(fp->dir)) continue; /* identified: not this one */
    if(_dir_has_table(fp->dir, table_hash))
    {
      g_strlcpy(dst, fp->dir, dstsz);
      found = TRUE;
    }
  }
  if(packs) g_ptr_array_unref(packs);
  return found;
}

/* how an edit resolves to a pack on this machine */
typedef enum sf_edit_pack_t
{
  SF_EDIT_PACK_OK = 0,     /* dir holds the pack this edit asks for */
  SF_EDIT_PACK_SUBSTITUTE, /* dir holds a pack, but not the one asked for */
  SF_EDIT_PACK_LEGACY_GONE,/* pre-identity edit on an identified pack */
  SF_EDIT_PACK_NONE,       /* nothing installed; dir names the hand-install folder */
} sf_edit_pack_t;

/* the pack this edit needs, and whether it is here.
 *
 * Three hashes answer three different questions:
 *
 *   pack_hash        the pack the edit was developed against. Two installed
 *                    packs can carry one table and render differently, so this
 *                    outranks the table.
 *   upsampling_hash  a table the edit was explicitly moved to. Choosing it
 *                    clears the other two, so until the next stamp it names
 *                    the only thing the edit asks for; once stamped, the
 *                    recorded pack has to carry it.
 *   lut_hash         the pack's default table as stamped, the weakest claim
 *                    and the only one a pre-identity edit has.
 *
 * dir always receives a directory: the matching pack for OK, the pack that
 * renders in its place for SUBSTITUTE and LEGACY_GONE, and the hand-install
 * folder for NONE so an error can name somewhere real. Anything but NONE
 * renders; the mismatch is reported, not refused, as a table mismatch always
 * was. out_table receives the table a download should ask for */
static sf_edit_pack_t _edit_pack_dir(const dt_iop_spektrafilm_params_t *p,
                                     char *dir,
                                     const size_t dstsz,
                                     uint32_t *out_table)
{
  const uint32_t want_table = p->upsampling_hash ? p->upsampling_hash : p->lut_hash;
  if(out_table) *out_table = want_table;

  if(p->pack_hash && sf_fetch_pack_dir_for_pack_hash(p->pack_hash, dir, dstsz)
     && (!p->upsampling_hash || _dir_has_table(dir, p->upsampling_hash)))
    return SF_EDIT_PACK_OK;

  /* an edit carrying a table but no pack was last rendered on a pack that
     declares no identity: either the edit predates pack_hash, or the pack it
     used does. Only such a pack is its own */
  const gboolean unidentified = !p->pack_hash && !p->upsampling_hash && p->lut_hash;
  if(unidentified && _legacy_pack_dir_with_table(p->lut_hash, dir, dstsz))
    return SF_EDIT_PACK_OK;

  gboolean exact = FALSE;
  if(p->upsampling_hash && _pack_dir_with_table(p->upsampling_hash, dir, dstsz))
    exact = TRUE;
  else if(!sf_fetch_resolve_pack_dir(p->lut_hash, dir, dstsz, &exact))
  {
    _pack_dir(dir, dstsz);
    return SF_EDIT_PACK_NONE;
  }

  /* the recorded pack is not here, so whatever was found stands in for it */
  if(p->pack_hash) return SF_EDIT_PACK_SUBSTITUTE;
  /* reaching an identified pack is a substitution: the table matches, so
     nothing objects, but a release can carry a table forward unchanged while
     its profiles move */
  if(unidentified && sf_fetch_peek_pack_hash(dir)) return SF_EDIT_PACK_LEGACY_GONE;
  /* an edit that records no table has no preference a pack could violate, so
     whatever the resolver found, a downloaded pack included, is the right one */
  return (exact || !want_table) ? SF_EDIT_PACK_OK : SF_EDIT_PACK_SUBSTITUTE;
}

/* Pick the pack directory for an edit that recorded wanted_lut_hash (0 = no
   preference). Local lookup only -- no network, safe on the pixelpipe. Falls
   back to the hand-install directory so error text still names somewhere real. */
static void _resolve_pack_dir(uint32_t wanted_lut_hash,
                              char *dst,
                              size_t dstsz)
{
  gboolean exact = FALSE;
  const gboolean found = sf_fetch_resolve_pack_dir(wanted_lut_hash, dst, dstsz, &exact);
  if(!found) _pack_dir(dst, dstsz);
  /* Which directory was chosen, and why, is the first thing worth knowing when
     the module renders nothing: it separates "no pack anywhere" from "found a
     pack, but it failed to load". */
  dt_print(DT_DEBUG_DEV,
           "[spektrafilm] pack for table %08x -> %s (%s)\n", wanted_lut_hash, dst,
           !found      ? "nothing found, falling back"
           : exact     ? "exact match"
           : !wanted_lut_hash ? "no table recorded, taking what is installed"
                       : "table mismatch, using anyway");
}

/* natural (human) string compare: embedded numbers compared numerically
   so "Vision3 50D" < "Vision3 200T" < "Vision3 500T" */
static int _nat_cmp(const char *a,
                    const char *b)
{
  for(;;)
  {
    if(*a == 0) return *b == 0 ? 0 : -1;
    if(*b == 0) return 1;
    int da = (unsigned)*a - '0' < 10u;
    int db = (unsigned)*b - '0' < 10u;
    if(da && db)
    {
      unsigned long va = 0, vb = 0;
      while((unsigned)*a - '0' < 10u) { va = va * 10 + (*a - '0'); a++; }
      while((unsigned)*b - '0' < 10u) { vb = vb * 10 + (*b - '0'); b++; }
      if(va != vb) return va < vb ? -1 : 1;
    }
    else if(da != db)
      return da ? -1 : 1;
    else
    {
      int ca = g_ascii_tolower(*a);
      int cb = g_ascii_tolower(*b);
      if(ca != cb) return ca < cb ? -1 : 1;
      a++; b++;
    }
  }
}

/* Trim filler out of a profile's display name, in place.

   The comboboxes are as wide as darktable's right panel and no wider, so a name
   like "Kodak Professional Portra Endura" truncates to "Kodak Professional ..."
   -- and four papers share that prefix, so the list became four identical
   entries. Only genuinely redundant words go: "Professional" says nothing, and
   "Negative Film" / "Reversal Film" duplicate the section heading the entry is
   already filed under. Everything that identifies the stock stays, including
   "Print Film 2302", where it is part of the name people know. */
static void _shorten_name(char *s,
                          const size_t sz)
{
  static const char *const filler[] = { " Professional", " Negative Film", " Reversal Film" };
  for(size_t f = 0; f < sizeof(filler) / sizeof(*filler); f++)
  {
    char *at = strstr(s, filler[f]);
    if(!at) continue;
    const size_t flen = strlen(filler[f]);
    memmove(at, at + flen, strlen(at + flen) + 1);
  }
  (void)sz;
}

static gint _entry_name_cmp(gconstpointer a,
                            gconstpointer b)
{
  return _nat_cmp(((const sf_prof_entry_t *)a)->name, ((const sf_prof_entry_t *)b)->name);
}

/* scan <packdir>/profiles/ (all .json files); reads only the info header of
   each profile (stock / name / stage / target_print). Returns a newly allocated
   list of sf_prof_entry_t, sorted by name, which the caller owns.

   packdir may be NULL, which means "whichever pack is currently loaded, or the
   one a fresh edit would resolve to". Profiles must come from the same pack as
   the spectral table: a profile names a stock, and the pack holds that stock's
   digested render defaults, so mixing the two silently drops per-film halation,
   grain and coupler data for any stock the other side has never heard of. */
/* one spectral upsampling table, as the combobox needs it. Snapshotted out of
   the pack rather than read live: the GUI has no pack reference of its own and
   the pack can be reloaded underneath it when the resolved directory changes */
typedef struct sf_table_entry_t
{
  char label[64];       /* the method name shown in the combobox */
  char dir[SF_PATH_LEN];/* the pack that carries it */
  uint32_t hash;
  gboolean reflectance;
  gboolean named;       /* label came from a declaration, not a header id */
} sf_table_entry_t;

/* every spectral upsampling table on this machine, across all installed packs.
   Not only the pack this edit resolves to: an edit made against 0.3.3 resolves
   to a pack carrying one table, and listing just that one would leave it no way
   to reach a method a later pack added: the control would be permanently
   inert on exactly the edits most likely to want it. Choosing a table from
   another pack moves the edit to that pack, which _upsampling_changed() does.

   Read from the directories and not from the loaded pack, for the same reason
   _scan_profiles() scans directories: _pack is assigned only by _ensure_sim(),
   which runs in the pixelpipe, so it is still NULL while the GUI is built.
   Peeking costs a pack.json parse and a 32-byte header per table */
static GList *_scan_tables(void)
{
  GList *list = NULL;
  GPtrArray *packs = sf_fetch_list_packs();
  for(guint pi = 0; packs && pi < packs->len; pi++)
  {
    const sf_fetch_pack_t *fp = g_ptr_array_index(packs, pi);
    sf_table_info_t info[SF_MAX_TABLES];
    const int n = sf_pack_peek_tables(fp->dir, info, SF_MAX_TABLES);
    dt_print(DT_DEBUG_DEV, "[spektrafilm] %d spectral upsampling table(s) in %s\n",
             n, fp->dir);
    for(int i = 0; i < n; i++)
    {
      /* one entry per table, not per pack: packs overlap, and 0.3.3's table is
         byte-identical to the one 0.3.4 carries. Two entries for it would read
         as two methods and pick between the packs arbitrarily. First pack
         wins, which is the precedence sf_fetch_list_packs() already reports */
      sf_table_entry_t *dup = NULL;
      for(GList *l = list; l && !dup; l = l->next)
        if(((sf_table_entry_t *)l->data)->hash == info[i].lut_hash)
          dup = l->data;
      if(dup)
      {
        /* A pack that names the table teaches the entry its method even when
           an earlier pack supplies the bytes: same table, and "hanatos2025"
           is what the method is called everywhere else. The directory stays
           the earlier pack's, precedence being about which files to read */
        if(!dup->named && info[i].identifier[0])
        {
          g_strlcpy(dup->label, info[i].identifier, sizeof dup->label);
          dup->named = TRUE;
        }
        continue;
      }

      sf_table_entry_t *e = g_malloc0(sizeof(*e));
      /* A format 2 pack's single table declared no identifier; its header id is
         the only name it has, and it is the one the mismatch banner and the
         data repository both use */
      e->named = info[i].identifier[0] != 0;
      g_strlcpy(e->label, e->named ? info[i].identifier : info[i].lut_id,
                sizeof e->label);
      g_strlcpy(e->dir, fp->dir, sizeof e->dir);
      e->hash = info[i].lut_hash;
      e->reflectance = info[i].kind == SF_LUT_REFLECTANCE;
      list = g_list_append(list, e);
      dt_print(DT_DEBUG_DEV, "[spektrafilm]   %08x %s %s\n", e->hash, e->label,
               info[i].lut_id);
    }
  }
  if(packs) g_ptr_array_unref(packs);

  /* nothing installed names the first published table, so fall back to what it
     is: only one pack ever declared no tables, and this is the one it held */
  for(GList *l = list; l; l = l->next)
  {
    sf_table_entry_t *e = l->data;
    if(!e->named && e->hash == SF_FIRST_PUBLISHED_LUT_HASH)
    {
      g_strlcpy(e->label, SF_FIRST_PUBLISHED_LUT_NAME, sizeof e->label);
      e->named = TRUE;
    }
  }
  return list;
}

static const sf_table_entry_t *_table_at(const dt_iop_spektrafilm_gui_data_t *g,
                                         const int pos)
{
  return g ? g_list_nth_data(g->tables, pos) : NULL;
}

static GList *_entries_copy(GList *src)
{
  GList *out = NULL;
  for(GList *l = src; l; l = l->next)
  {
    sf_prof_entry_t *e = g_malloc(sizeof(sf_prof_entry_t));
    memcpy(e, l->data, sizeof(sf_prof_entry_t));
    out = g_list_prepend(out, e);
  }
  return g_list_reverse(out);
}

static GList *_scan_profiles(const char *packdir)
{
  char dir[SF_PATH_LEN];
  if(packdir && *packdir)
    g_strlcpy(dir, packdir, sizeof dir);
  else
  {
    dt_pthread_mutex_lock(&_pack_lock);
    /* _pack_path also records directories that failed to load, so it only
       answers "which pack is in use" when one is actually held. */
    const gboolean have = _pack && _pack_path[0] != 0;
    if(have) g_strlcpy(dir, _pack_path, sizeof dir);
    dt_pthread_mutex_unlock(&_pack_lock);
    if(!have) _resolve_pack_dir(0, dir, sizeof dir);
  }
  char profdir[SF_PATH_LEN + 16];
  snprintf(profdir, sizeof profdir, "%s/profiles", dir);

  GStatBuf pst;
  const gint64 prof_mtime = (g_stat(profdir, &pst) == 0) ? (gint64)pst.st_mtime : -1;
  const guint prof_gen = sf_fetch_generation();
  g_mutex_lock(&_prof_cache_lock);
  if(_prof_cache && prof_mtime >= 0 && _prof_cache_mtime == prof_mtime
     && _prof_cache_gen == prof_gen && strcmp(_prof_cache_dir, profdir) == 0)
  {
    GList *copy = _entries_copy(_prof_cache);
    g_mutex_unlock(&_prof_cache_lock);
    return copy;
  }
  g_mutex_unlock(&_prof_cache_lock);

  GDir *gd = g_dir_open(profdir, 0, NULL);
  if(!gd) return NULL;
  GList *list = NULL;
  const char *fn;
  while((fn = g_dir_read_name(gd)))
  {
    if(!g_str_has_suffix(fn, ".json")) continue;
    char path[SF_PATH_LEN + 300];
    snprintf(path, sizeof path, "%s/%s", profdir, fn);
    char *err = NULL;
    sf_profile_t *prof = sf_profile_load(path, 0.5f, &err); /* info header only */
    if(!prof)
    {
      free(err);
      continue;
    }
    sf_prof_entry_t *e = g_malloc0(sizeof(sf_prof_entry_t));
    g_strlcpy(e->stock, sf_profile_stock(prof) ? sf_profile_stock(prof) : fn, SF_NAME_LEN);
    /* strip .json when falling back to the file name */
    char *dot = strstr(e->stock, ".json");
    if(dot) *dot = 0;
    g_strlcpy(e->name, sf_profile_name(prof) ? sf_profile_name(prof) : e->stock, SF_NAME_LEN);
    const char *stage = sf_profile_stage(prof);
    e->printing = (stage && !strcmp(stage, "printing"));
    const char *tp = sf_profile_target_print(prof);
    if(tp) g_strlcpy(e->target_print, tp, SF_NAME_LEN);
    const char *type = sf_profile_type(prof);
    e->positive = (type && !strcmp(type, "positive"));
    _shorten_name(e->name, sizeof e->name);
    const char *cm = sf_profile_channel_model(prof);
    e->bw = (cm && !strcmp(cm, "bw"));
    e->n_dev = sf_profile_dev_times(prof, e->dev_times, SF_MAX_DEV_TIMES);
    e->hash = _name_hash(e->stock);
    sf_profile_free(prof);
    list = g_list_prepend(list, e);
  }
  g_dir_close(gd);
  /* natural order by display name (numbers compared numerically,
     so "50D" < "200T" instead of lexicographic "200T" < "50D") */
  list = g_list_sort(list, _entry_name_cmp);

  /* an empty result is not kept: it is what a download in progress or a
     half-deleted pack looks like, and the next call should look again */
  if(list && prof_mtime >= 0)
  {
    g_mutex_lock(&_prof_cache_lock);
    g_list_free_full(_prof_cache, g_free);
    _prof_cache = _entries_copy(list);
    g_strlcpy(_prof_cache_dir, profdir, sizeof _prof_cache_dir);
    _prof_cache_mtime = prof_mtime;
    _prof_cache_gen = prof_gen;
    g_mutex_unlock(&_prof_cache_lock);
  }
  return list;
}

/* resolve a profile hash to its stock name. hash 0 -> default:
   for films the first filming stock, for papers prefer the film's
   target_print. Returns false when nothing matches. */
/* prefer_bw: 0 or 1 to prefer a stock with that channel model when the named
   one is absent, -1 for no preference. It is the tier _auto_paper_entry()
   applies in the GUI, and it has to be applied here too or the two disagree:
   target_print is optional in the pack, the entry list is sorted by display
   name, and so the last-resort first printing entry bears no relation to the
   film: for a black-and-white negative it is a color paper */
static gboolean _resolve_stock(GList *entries,
                               uint32_t hash,
                               gboolean want_printing,
                               const char *prefer_stock,
                               int prefer_bw,
                               char *dst,
                               size_t dstsz)
{
  if(hash)
    for(GList *l = entries; l; l = l->next)
    {
      const sf_prof_entry_t *e = l->data;
      if(e->hash == hash && e->printing == want_printing)
      {
        g_strlcpy(dst, e->stock, dstsz);
        return TRUE;
      }
    }
  if(prefer_stock && prefer_stock[0])
    for(GList *l = entries; l; l = l->next)
    {
      const sf_prof_entry_t *e = l->data;
      if(e->printing == want_printing && !strcmp(e->stock, prefer_stock))
      {
        g_strlcpy(dst, e->stock, dstsz);
        return TRUE;
      }
    }
  if(prefer_bw >= 0)
    for(GList *l = entries; l; l = l->next)
    {
      const sf_prof_entry_t *e = l->data;
      if(e->printing == want_printing && e->bw == (prefer_bw != 0))
      {
        g_strlcpy(dst, e->stock, dstsz);
        return TRUE;
      }
    }
  for(GList *l = entries; l; l = l->next)
  {
    const sf_prof_entry_t *e = l->data;
    if(e->printing == want_printing)
    {
      g_strlcpy(dst, e->stock, dstsz);
      return TRUE;
    }
  }
  return FALSE;
}

/* ---------------------------------------------------------------------- */
/* pipeline plumbing                                                      */
/* ---------------------------------------------------------------------- */

void init_pipe(dt_iop_module_t *self,
               dt_dev_pixelpipe_t *pipe,
               dt_dev_pixelpipe_iop_t *piece)
{
  dt_iop_spektrafilm_data_t *d = calloc(1, sizeof(dt_iop_spektrafilm_data_t));
  /* NULL on failure: every consumer of piece->data tests for it and passes
     the image through */
  if(d) dt_pthread_mutex_init(&d->lock);
  piece->data = d;
}

void cleanup_pipe(dt_iop_module_t *self,
                  dt_dev_pixelpipe_t *pipe,
                  dt_dev_pixelpipe_iop_t *piece)
{
  dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data;
  if(d)
  {
    if(d->gpu) sf_sim_gpu_free(d->gpu);
    if(d->sim) sf_sim_free(d->sim);
#ifdef HAVE_OPENCL
    if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax);
    if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart);
    if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin);
    if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total);
    if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve);
#endif
    dt_pthread_mutex_destroy(&d->lock);
  }
  free(piece->data);
  piece->data = NULL;
}

void commit_params(dt_iop_module_t *self,
                   dt_iop_params_t *p1,
                   dt_dev_pixelpipe_t *pipe,
                   dt_dev_pixelpipe_iop_t *piece)
{
  dt_iop_spektrafilm_data_t *d = (dt_iop_spektrafilm_data_t *)piece->data;
  if(!d) return;
  d->p = *(dt_iop_spektrafilm_params_t *)p1;
  /* Only the preview pipe reports status to the module banner; see the field's
     declaration. Re-set on every commit because a pipe can be rebuilt. */
  d->self = dt_pipe_is_preview(pipe) ? self : NULL;
  /* the sim itself is (re)built lazily in process(), where the pipe's work
     profile is reliably known; a stale sim is detected via sim_key there. */
}

static uint64_t _mix64(uint64_t h,
                       const void *data,
                       size_t len)
{
  const unsigned char *p = data;
  for(size_t i = 0; i < len; i++)
  {
    h ^= p[i];
    h *= 0x100000001b3ULL; /* FNV-1a 64 */
  }
  return h;
}

/* make sure d->sim matches the current params + work profile; returns the sim
   or NULL (passthrough). Called from process() under no assumption of being
   single-threaded (full/preview pipes run concurrently). */
/* forward: _ensure_sim() posts the banner refresh from the pipe thread */
static gboolean _trouble_idle_cb(gpointer user_data);
static void _update_trouble_message(dt_iop_module_t *self);

/* Hand this run's verdict to the GUI. Called at every exit from _ensure_sim(),
   including the early ones: a failure returns before the build and its message
   is exactly the one worth showing, so every pack and profile failure has to
   publish from where it is detected.

   Only the preview pipe carries d->self, so only one pipe writes. The banner is
   redrawn from gui_update(), which a pipe run does not call, so a change posts a
   refresh through the main loop -- and only on a change, leaving a steady error
   free. Call with d->lock held.

   The status strings live in gui_data and are read on the GTK thread, so
   d->lock -- which is per piece -- does not order those accesses against each
   other. Take the module's GUI critical section for the compare-and-copy; the
   readers take the same one. */
static void _publish_status(const dt_iop_spektrafilm_data_t *d)
{
  dt_iop_module_t *self = d->self;
  if(!self) return;
  /* gui_data is read inside the section, which gui_cleanup() also takes, so
     the whole test-and-write is ordered against teardown: the module cannot be
     dismantled between finding a GUI here and writing into it. */
  dt_iop_gui_enter_critical_section(self);
  dt_iop_spektrafilm_gui_data_t *g = self->gui_data;
  if(g)
  {
    const gboolean changed = strcmp(g->status_error, d->sim_error) != 0
                             || strcmp(g->status_warning, d->sim_warning) != 0;
    if(changed)
    {
      g_strlcpy(g->status_error, d->sim_error, sizeof g->status_error);
      g_strlcpy(g->status_warning, d->sim_warning, sizeof g->status_warning);
    }
    if(changed && !g->trouble_idle)
      g->trouble_idle = g_idle_add(_trouble_idle_cb, self);
  }
  dt_iop_gui_leave_critical_section(self);
}

/* Bring the cached sim's two output gains, and their float mirrors in the GPU
   export, up to the current params. They are not part of the sim key. Call
   with d->lock held; the piece's own pipe is the only thing rendering with
   d->sim, and it is the caller. */
static void _apply_output_gain(dt_iop_spektrafilm_data_t *d)
{
  if(!d->sim) return;
  sf_sim_set_output_gain(d->sim, d->p.output_luminance_boost, d->p.output_scale);
  if(d->gpu)
  {
    d->gpu->out_luminance_boost = d->p.output_luminance_boost;
    d->gpu->out_scale = d->p.output_scale;
  }
}

static sf_sim_t *_ensure_sim(dt_iop_spektrafilm_data_t *d,
                             const dt_iop_order_iccprofile_info_t *work_profile)
{
  const dt_iop_spektrafilm_params_t *p = &d->p;

  /* the work profile's RGB<->XYZ matrices feed the engine; include them in
     the cache key so a work-profile change rebuilds the sim */
  float m_in[9], m_out[9];
  for(int i = 0; i < 3; i++)
    for(int j = 0; j < 3; j++)
    {
      /* dt_colormatrix_t, row-major: XYZ_i = sum_j matrix_in[i][j] * RGB_j */
      m_in[i * 3 + j] = work_profile->matrix_in[i][j];
      m_out[i * 3 + j] = work_profile->matrix_out[i][j];
    }

  uint64_t key = 0xcbf29ce484222325ULL;
  key = _mix64(key, &p->film_hash, sizeof p->film_hash);
  key = _mix64(key, &p->lut_hash, sizeof p->lut_hash);
  key = _mix64(key, &p->pack_hash, sizeof p->pack_hash);
  /* picks the spectral table the tc LUT is built from, so the sim is a
     different one entirely */
  key = _mix64(key, &p->upsampling_hash, sizeof p->upsampling_hash);
  key = _mix64(key, &p->paper_hash, sizeof p->paper_hash);
  key = _mix64(key, &p->exposure_ev, sizeof p->exposure_ev);
  key = _mix64(key, &p->print_exposure_ev, sizeof p->print_exposure_ev);
  key = _mix64(key, &p->print_auto_exposure, sizeof p->print_auto_exposure);
  key = _mix64(key, &p->print_contrast, sizeof p->print_contrast);
  /* morph the paper curves at build time, exactly as print_contrast does */
  key = _mix64(key, &p->print_gamma_r, sizeof p->print_gamma_r);
  key = _mix64(key, &p->print_gamma_g, sizeof p->print_gamma_g);
  key = _mix64(key, &p->print_gamma_b, sizeof p->print_gamma_b);
  key = _mix64(key, &p->filter_m, sizeof p->filter_m);
  key = _mix64(key, &p->filter_y, sizeof p->filter_y);
  key = _mix64(key, &p->couplers_amount, sizeof p->couplers_amount);
  key = _mix64(key, &p->couplers_diffusion_um, sizeof p->couplers_diffusion_um);
  key = _mix64(key, &p->couplers_tail_um, sizeof p->couplers_tail_um);
  key = _mix64(key, &p->couplers_tail_weight, sizeof p->couplers_tail_weight);
  key = _mix64(key, &p->couplers_inhibition_same, sizeof p->couplers_inhibition_same);
  key = _mix64(key, &p->couplers_inhibition_inter, sizeof p->couplers_inhibition_inter);
  key = _mix64(key, &p->preflash_exposure, sizeof p->preflash_exposure);
  key = _mix64(key, &p->preflash_m_shift, sizeof p->preflash_m_shift);
  key = _mix64(key, &p->preflash_y_shift, sizeof p->preflash_y_shift);
  key = _mix64(key, &p->scan_film, sizeof p->scan_film);
  /* selects which member of a B&W development-time family the curve model is
     built from, so it has to be part of the sim's cache key */
  key = _mix64(key, &p->development_min, sizeof p->development_min);
  key = _mix64(key, &p->print_development_min, sizeof p->print_development_min);
  /* these three rebuild the grain layer tables, which live in the sim */
  key = _mix64(key, &p->grain_granularity, sizeof p->grain_granularity);
  key = _mix64(key, &p->grain_uniformity, sizeof p->grain_uniformity);
  key = _mix64(key, &p->grain_sublayer_scale, sizeof p->grain_sublayer_scale);
  key = _mix64(key, &p->grain_density_min, sizeof p->grain_density_min);
  /* both change the tc LUT, which is built once per sim */
  key = _mix64(key, &p->adaptation_bandwidth, sizeof p->adaptation_bandwidth);
  key = _mix64(key, &p->adaptation_surface, sizeof p->adaptation_surface);
  /* output_luminance_boost and output_scale are deliberately absent: both are
     plain multipliers applied at scan time and enter no table, so they are
     written into the cached sim by _apply_output_gain() instead of costing a
     rebuild on every slider step */
  /* the endpoint fit is solved at build time, not per pixel */
  key = _mix64(key, &p->scan_black_correction, sizeof p->scan_black_correction);
  key = _mix64(key, &p->scan_white_correction, sizeof p->scan_white_correction);
  key = _mix64(key, &p->scan_black_level, sizeof p->scan_black_level);
  key = _mix64(key, &p->scan_white_level, sizeof p->scan_white_level);
  key = _mix64(key, &p->gamut_compress, sizeof p->gamut_compress);
  key = _mix64(key, &p->film_gamma_factor, sizeof p->film_gamma_factor);
  key = _mix64(key, &p->film_gamma_factor_fast, sizeof p->film_gamma_factor_fast);
  key = _mix64(key, &p->film_gamma_factor_slow, sizeof p->film_gamma_factor_slow);
  key = _mix64(key, &p->film_developer_exhaustion, sizeof p->film_developer_exhaustion);
  key = _mix64(key, &p->push_pull_stops, sizeof p->push_pull_stops);
  key = _mix64(key, m_in, sizeof m_in);
  key = _mix64(key, m_out, sizeof m_out);
  /* Installing a data pack changes no parameter, but it changes the data the sim
     was built from and any mismatch warning it recorded, so the generation has
     to be part of the key. It is bumped once per successful install, which
     makes this force a rebuild exactly when new data arrived and never
     otherwise. */
  const guint fetch_gen = sf_fetch_generation();
  key = _mix64(key, &fetch_gen, sizeof fetch_gen);

  dt_pthread_mutex_lock(&d->lock);
  /* a hit, or a build this key already failed: sim_error still holds why */
  if(d->sim_key == key && (d->sim || d->sim_tried))
  {
    sf_sim_t *s = d->sim;
    _apply_output_gain(d);
    _publish_status(d);
    dt_pthread_mutex_unlock(&d->lock);
    return s;
  }

  /* (re)build */
  if(d->gpu)
  {
    sf_sim_gpu_free(d->gpu);
    d->gpu = NULL;
    /* these were uploaded from this gpu's grain_layer_* tables; release them
       now so process_cl() re-uploads fresh ones from the new gpu instead of
       reusing now-stale content. */
#ifdef HAVE_OPENCL
    if(d->grain_cl_dmax) { dt_opencl_release_mem_object(d->grain_cl_dmax); d->grain_cl_dmax = NULL; }
    if(d->grain_cl_npart) { dt_opencl_release_mem_object(d->grain_cl_npart); d->grain_cl_npart = NULL; }
    if(d->grain_cl_dmin) { dt_opencl_release_mem_object(d->grain_cl_dmin); d->grain_cl_dmin = NULL; }
    if(d->grain_cl_total) { dt_opencl_release_mem_object(d->grain_cl_total); d->grain_cl_total = NULL; }
    if(d->grain_cl_curve) { dt_opencl_release_mem_object(d->grain_cl_curve); d->grain_cl_curve = NULL; }
    d->grain_cl_built_for = NULL;
#endif
  }
  if(d->sim)
  {
    sf_sim_free(d->sim);
    d->sim = NULL;
  }
  d->sim_key = key;
  d->sim_tried = FALSE;
  d->sim_error[0] = 0;
  d->sim_warning[0] = 0;

  /* Global pack, loaded once per resolved directory.
     Which directory that is depends on the spectral table this edit recorded,
     so switching to an image developed against a different table reloads. One pack is held at a
     time:
     the LUT is ~12 MB, and having two images from different table generations
     open in the same session is rare enough that the reload costs less than
     permanently carrying every pack the user has on disk. */
  char want_dir[SF_PATH_LEN];
  /* one lookup, shared with the module's data row: the two disagreeing about
     which pack an edit needs is what left an edit unrenderable while the row
     reported nothing missing */
  const sf_edit_pack_t edit_pack = _edit_pack_dir(p, want_dir, sizeof want_dir, NULL);
  if(edit_pack != SF_EDIT_PACK_OK)
    dt_print(DT_DEBUG_DEV, "[spektrafilm] edit's own pack is not installed (%d); using %s\n",
             (int)edit_pack, want_dir);

  const guint gen = fetch_gen;

  dt_pthread_mutex_lock(&_pack_lock);
  /* The loaded pack and the error from failing to load both belong to one
     directory at one point in time, so both go stale on either axis: the
     resolved directory changing, or a download changing what that directory
     holds. Both axes are tested, and the test runs whether or not a pack is
     held -- otherwise _pack_error would latch after the first failure and a
     pack installed mid-session would never be picked up. */
  if(strcmp(_pack_path, want_dir) != 0 || _pack_gen != gen)
  {
    if(_pack) sf_pack_free(_pack);
    _pack = NULL;
    _pack_error[0] = 0;
    _pack_path[0] = 0;
  }
  if(!_pack && !_pack_error[0])
  {
    char *err = NULL;
    sf_pack_status_t status = SF_PACK_ERR_UNREADABLE;
    _pack = sf_pack_load(want_dir, &err, &status);
    /* Record the attempt on failure too: _pack_path is what the staleness
       check above compares against, and leaving it empty after a failed load
       would make every later call look stale and retry the same doomed load
       once per pipe run. */
    g_strlcpy(_pack_path, want_dir, sizeof _pack_path);
    _pack_gen = gen;
    if(!_pack)
    {
      /* The engine's own text names files and internal functions, which helps
         in a log and not at all in the module header. Say what happened and
         what to do about it there, and keep the detail for -d dev. */
      const char *msg =
          status == SF_PACK_ERR_TOO_NEW
              ? _("this data pack was made for a newer version of darktable\n"
                  "update darktable to use it")
          : status == SF_PACK_ERR_TOO_OLD
              ? _("this data pack is too old for this version of darktable\n"
                  "use the download button to fetch a current one")
              : _("the data pack could not be read\n"
                  "it may have downloaded incompletely -- use the download button"
                  " to fetch it again");
      g_strlcpy(_pack_error, msg, sizeof _pack_error);
      dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", err ? err : "unknown");
      free(err);
    }
    else
      dt_print(DT_DEBUG_DEV, "[spektrafilm] loaded data pack %s (spektrafilm %s)\n",
               want_dir, sf_pack_version(_pack));
  }
  /* Take a reference for the rest of this call. _pack is process-global and
     another thread entering here -- a second pixelpipe, or the colour picker
     from the GUI thread -- frees it whenever it resolves a different pack
     directory or sees a newer fetch generation, both of which are ordinary
     configurations rather than corner cases. The lock excludes only threads
     inside the critical section, not one that has already left holding the
     pointer, and the build below reads the spectra, the film defaults and the
     illuminants for its whole multi-hundred-millisecond duration. */
  sf_pack_t *pack = sf_pack_ref(_pack);
  char pack_dir[SF_PATH_LEN];
  char pack_error[sizeof _pack_error];
  g_strlcpy(pack_dir, _pack_path, sizeof pack_dir);
  g_strlcpy(pack_error, _pack_error, sizeof pack_error);
  dt_pthread_mutex_unlock(&_pack_lock);
  if(!pack)
  {
    /* Without this the module renders nothing behind an empty trouble banner
       and the reason only ever reaches the terminal. */
    g_strlcpy(d->sim_error,
              pack_error[0] ? pack_error
                            : _("no data pack installed\n"
                                "use the download button in the module to fetch one"),
              sizeof d->sim_error);
    _publish_status(d);
    dt_pthread_mutex_unlock(&d->lock);
    return NULL;
  }

  /* From here on a failure belongs to this key: the pack is loaded, so what
     can still go wrong is a profile or the build, and neither changes until a
     parameter or the installed data does -- both of which change the key. A
     missing pack above is left retrying on purpose: one copied in by hand
     bumps no generation, and that path returns before anything expensive. */
  d->sim_tried = TRUE;

  /* resolve stocks */
  GList *entries = _scan_profiles(pack_dir);
  char film_stock[SF_NAME_LEN] = { 0 }, paper_stock[SF_NAME_LEN] = { 0 };
  if(!_resolve_stock(entries, p->film_hash, FALSE, "kodak_gold_200", -1, film_stock,
                     sizeof film_stock))
  {
    g_strlcpy(d->sim_error,
              _("the installed data pack contains no film stocks\n"
                "it may have downloaded incompletely -- try fetching it again"),
              sizeof d->sim_error);
    dt_print(DT_DEBUG_DEV, "[spektrafilm] no filming profiles under %s/profiles\n",
             pack_dir);
    g_list_free_full(entries, g_free);
    sf_pack_free(pack);
    _publish_status(d);
    dt_pthread_mutex_unlock(&d->lock);
    return NULL;
  }
  const char *target_print = NULL;
  int film_bw = -1;
  for(GList *l = entries; l; l = l->next)
  {
    const sf_prof_entry_t *e = l->data;
    if(!e->printing && !strcmp(e->stock, film_stock))
    {
      target_print = e->target_print;
      film_bw = e->bw ? 1 : 0;
    }
  }
  if(!p->scan_film
     && !_resolve_stock(entries, p->paper_hash, TRUE, target_print, film_bw,
                        paper_stock, sizeof paper_stock))
  {
    g_strlcpy(d->sim_error,
              _("the installed data pack contains no print papers\n"
                "it may have downloaded incompletely -- try fetching it again"),
              sizeof d->sim_error);
    dt_print(DT_DEBUG_DEV, "[spektrafilm] no printing profiles under %s/profiles\n",
             pack_dir);
    g_list_free_full(entries, g_free);
    sf_pack_free(pack);
    _publish_status(d);
    dt_pthread_mutex_unlock(&d->lock);
    return NULL;
  }

  g_list_free_full(entries, g_free); /* stocks resolved; the list is done */

  /* Load from the pack that was actually resolved, NOT from the config
     directory. _scan_profiles() above lists the resolved pack's profiles, so
     using a different directory here means the stock names resolve against one
     pack and the files are read from another -- with a downloaded pack and
     nothing hand-installed, every load simply misses and the module goes quiet
     for want of a film. */
  char path[SF_PATH_LEN + 300];
  char *err = NULL;
  snprintf(path, sizeof path, "%s/profiles/%s.json", pack_dir, film_stock);
  sf_profile_t *film = sf_profile_load(path, p->development_min, &err);
  if(!film)
    dt_print(DT_DEBUG_DEV, "[spektrafilm] cannot load film profile %s: %s\n", path,
             err ? err : "unknown");
  sf_profile_t *paper = NULL;
  if(film && !p->scan_film)
  {
    snprintf(path, sizeof path, "%s/profiles/%s.json", pack_dir, paper_stock);
    paper = sf_profile_load(path, p->print_development_min, &err);
    if(!paper)
      dt_print(DT_DEBUG_DEV, "[spektrafilm] cannot load print profile %s: %s\n", path,
               err ? err : "unknown");
  }

  /* Report a profile that will not load from here. The build below only runs
     with a film in hand, so a load failure never reaches its own error
     reporting and would otherwise render as a silently disabled module. */
  if(!film || (!paper && !p->scan_film))
    g_strlcpy(d->sim_error, err ? err : _("this film or paper could not be loaded"),
              sizeof d->sim_error);

  if(film && (paper || p->scan_film))
  {
    sf_sim_params_t sp;
    sf_sim_params_defaults(&sp);
    sp.exposure_comp_ev = p->exposure_ev - p->push_pull_stops;
    sp.print_exposure = powf(2.0f, p->print_exposure_ev);
    sp.print_exposure_compensation = p->print_auto_exposure; /* normalize_print_exposure
                                       stays at sf_sim_params_defaults' true — that combination
                                       is what gives f_mid (a fixed reference midgray density)
                                       when this toggle is off, i.e. film exposure then has its
                                       full, uncompensated effect on brightness; see
                                       sf_sim_build's midgray_factor branches */
    sp.m_filter_shift = p->filter_m;
    sp.y_filter_shift = p->filter_y;
    sp.couplers_active = (p->couplers_amount > 0.0f);
    sp.couplers_amount = p->couplers_amount;
    sp.grain_rms_scale = p->grain_granularity;
    sp.grain_uniformity_scale = p->grain_uniformity;
    sp.grain_particle_scale = p->grain_sublayer_scale;
    sp.grain_density_min_scale = p->grain_density_min;
    sp.coupler_diffusion_um = p->couplers_diffusion_um;
    sp.coupler_tail_um = p->couplers_tail_um;
    sp.coupler_tail_weight = p->couplers_tail_weight;
    sp.inhibition_samelayer = p->couplers_inhibition_same;
    sp.inhibition_interlayer = p->couplers_inhibition_inter;
    sp.preflash_exposure = p->preflash_exposure;
    sp.preflash_m_shift = p->preflash_m_shift;
    sp.preflash_y_shift = p->preflash_y_shift;
    sp.scan_film = p->scan_film;
    sp.spectral_lut_hash = p->upsampling_hash;
    sp.scan_black_correction = p->scan_black_correction;
    sp.scan_white_correction = p->scan_white_correction;
    sp.scan_black_level = p->scan_black_level;
    sp.scan_white_level = p->scan_white_level;
    sp.adaptation_bandwidth = p->adaptation_bandwidth;
    sp.adaptation_surface = p->adaptation_surface;
    sp.lut_steps = SF_LUT_STEPS;
    sp.out_luminance_boost = p->output_luminance_boost;
    if(!p->gamut_compress) sp.output_compress = SF_OUTPUT_COMPRESS_OFF;
    sp.out_scale = p->output_scale;
    if(p->print_contrast != 1.0f || p->print_gamma_r != 1.0f
       || p->print_gamma_g != 1.0f || p->print_gamma_b != 1.0f)
    {
      sp.morph_active = true;
      sp.morph_gamma = p->print_contrast;
      sp.morph_gamma_r = p->print_gamma_r;
      sp.morph_gamma_g = p->print_gamma_g;
      sp.morph_gamma_b = p->print_gamma_b;
    }
    if(p->film_gamma_factor != 1.0f || p->film_gamma_factor_fast != 1.0f
       || p->film_gamma_factor_slow != 1.0f || p->film_developer_exhaustion != 0.0f
       || p->push_pull_stops != 0.0f)
    {
      sp.film_morph_active = true;
      sp.film_morph_gamma = p->film_gamma_factor
                             * powf(SF_PUSH_PULL_GAMMA_PER_STOP, p->push_pull_stops);
      sp.film_morph_gamma_fast = p->film_gamma_factor_fast;
      sp.film_morph_gamma_slow = p->film_gamma_factor_slow;
      sp.film_morph_developer_exhaustion = p->film_developer_exhaustion;
    }
    /* darktable pipeline XYZ is D50-relative; the work profile matrices map
       work RGB <-> that XYZ, so both engine whites are D50 */
    static const double d50_xy[2] = { 0.3457, 0.3585 };
    for(int i = 0; i < 9; i++)
    {
      sp.input_rgb_to_xyz[i] = m_in[i];
      sp.output_rgb_to_xyz[i] = m_in[i];
      sp.output_xyz_to_rgb[i] = m_out[i];
    }
    sp.input_white_xy[0] = sp.output_white_xy[0] = d50_xy[0];
    sp.input_white_xy[1] = sp.output_white_xy[1] = d50_xy[1];

    /* Compare what this edit was developed against with what is installed. Only
       when the edit actually recorded one -- a 0 means the field predates the
       edit, not that anything is wrong. */
    /* a substitute pack need not carry the table the edit chose; render on its
       default rather than not at all, and say so below */
    if(sp.spectral_lut_hash && sf_pack_table_by_hash(pack, sp.spectral_lut_hash) < 0)
    {
      snprintf(d->sim_warning, sizeof d->sim_warning,
               _("developed with a spectral table this data pack does not carry\n"
                 "recorded: %08x    rendering with: %s\n"
                 "the matching pack can be fetched with the button below"),
               sp.spectral_lut_hash, sf_pack_lut_id(pack));
      sp.spectral_lut_hash = 0u;
    }
    else if(edit_pack == SF_EDIT_PACK_LEGACY_GONE)
      g_strlcpy(d->sim_warning,
                _("made before data packs carried an identity, and the pack it\n"
                  "was made with is not installed: rendering with a later one\n"
                  "the matching pack can be fetched with the button below"),
                sizeof d->sim_warning);
    else if(edit_pack == SF_EDIT_PACK_SUBSTITUTE && p->pack_hash)
      snprintf(d->sim_warning, sizeof d->sim_warning,
               _("developed with data pack %08x, which is not installed\n"
                 "rendering with %s\n"
                 "the matching pack can be fetched with the button below"),
               p->pack_hash, sf_pack_version(pack));

    const uint32_t pack_lut = sf_pack_lut_hash(pack);
    if(!d->sim_warning[0] && p->lut_hash && pack_lut && p->lut_hash != pack_lut)
      /* Print the hash as well as the name. The name carries the spektrafilm
         version string, and that is not a reliable identifier: an editable dev
         install reports whatever pyproject.toml says, so two materially
         different checkouts can both call themselves the same thing. Without
         the hash the message reads as nonsense when they do. */
      snprintf(d->sim_warning, sizeof d->sim_warning,
               _("developed with a different spectral table\n"
                 "recorded: %08x    installed: %s (%08x)\n"
                 "the matching pack can be fetched with the button below"),
               p->lut_hash, sf_pack_lut_id(pack), pack_lut);

    d->sim = sf_sim_build(pack, film, paper, &sp, &err);
    if(!d->sim && err)
    {
      g_strlcpy(d->sim_error, err, sizeof d->sim_error);
      dt_print(DT_DEBUG_DEV, "[spektrafilm] %s\n", err);
    }
    else if(d->sim)
    {
      /* float tables for the GPU path */
      d->gpu = sf_sim_gpu_export(d->sim);
      dt_print(DT_DEBUG_DEV, "[spektrafilm] built sim: %s -> %s (steps %d, gpu %s)\n",
               film_stock, p->scan_film ? "(scan film)" : paper_stock, sp.lut_steps,
               d->gpu ? "yes" : "no");
    }
  }
  free(err);
  if(film) sf_profile_free(film);
  if(paper) sf_profile_free(paper);

  sf_pack_free(pack); /* the reference taken above; the sim keeps no pointer into it */

  sf_sim_t *s = d->sim;
  /* Hand the outcome to the GUI while it is fresh. The banner is set from
     gui_update(), which has no piece to read this out of, and the strings are
     only ever written here -- so copying them out at the one point they settle
     is both the cheapest and the only reliable moment. Guarded on the module
     having a GUI at all, since export pipes run with none. */
  _publish_status(d);
  dt_pthread_mutex_unlock(&d->lock);
  return s;
}

/* ---------------------------------------------------------------------- */
/* ROI / tiling: expand the input by the spatial-effect halo               */
/* ---------------------------------------------------------------------- */

/* Widest Gaussian sigma (micrometres on film) one diffusion-filter stage will
   dispatch, or 0 when the stage is off / a no-op. Built from the same
   sf_diffusion_build_plan() the CPU and GPU paths run, so the ROI padding can
   never drift from the bank that is actually convolved. */
static float _diffusion_pad_sigma_um(const gboolean on,
                                     const int family,
                                     const float strength,
                                     const float warmth,
                                     const float scale)
{
  if(!on) return 0.0f;
  sf_diffusion_plan_t plan;
  if(!sf_diffusion_build_plan(family, strength, warmth, &plan) || plan.p_s <= 0.0f) return 0.0f;
  float smax = 0.0f;
  for(int j = 0; j < plan.n; j++) smax = fmaxf(smax, plan.sigma_um[j]);
  return smax * fmaxf(scale, 1e-3f);
}

static float _max_halo_sigma(const dt_iop_spektrafilm_params_t *p,
                             float pixel_um)
{
  const float inv_um = 1.0f / fmaxf(pixel_um, 1e-3f);
  /* halation stage: first-bounce radius, scaled by halation_scale. The scale
     has to be in the padding or a value above 1.0 under-pads. */
  const float hal_scale = fmaxf(p->halation_scale, 1e-3f);
  const float hal = (p->halation_on && p->halation_amount > 0.0f)
                        ? SF_HALATION_FIRST_SIGMA_UM * SF_HALATION_PSF_SIGMAS * hal_scale * inv_um
                        : 0.0f;
  /* scatter stage: widest core+tail component, scaled by its own
     scatter_scale (independent from halation_scale since the scatter_amount/
     scatter_scale split). */
  const float scat_scale = fmaxf(p->scatter_scale, 1e-3f);
  const float scat = (p->halation_on && p->scatter_amount > 0.0f)
                         /* SF_SCATTER_TAIL_MAX_UM is already the widest tail
                            component's sigma, so it needs no further factor.
                            SF_HALATION_PSF_SIGMAS is sqrt(n_bounces) and
                            belongs to the halation term above only; applying
                            it here as well would pad scatter 1.73x wider than
                            anything it dispatches. */
                         ? SF_SCATTER_TAIL_MAX_UM * scat_scale * inv_um
                         : 0.0f;
  /* The widest of film-stage and print-stage diffusion determines the ROI
     padding — both must fit in the expanded tile. Take the widest component of
     the actual Gaussian bank each stage will dispatch: the bloom scale differs by 2.6x across the
     four families (BPM
     380*2.5 um vs cinebloom 1000*2.5 um), so one constant either under-pads the
     wide families or over-pads the narrow ones. */
  const float diff = fmaxf(_diffusion_pad_sigma_um(p->diffusion_on,
                                                   (int)p->diffusion_filter_family,
                                                   p->diffusion_strength, p->diffusion_warmth,
                                                   p->diffusion_scale),
                           _diffusion_pad_sigma_um(p->print_diffusion_on,
                                                   (int)p->print_diffusion_filter_family,
                                                   p->print_diffusion_strength,
                                                   p->print_diffusion_warmth,
                                                   p->print_diffusion_scale))
                     * inv_um;
  /* grain stage: the clump blur and the acutance recovery are dispatched in
     pixels rather than micrometres, so neither goes through inv_um.

     The clump blur is a fixed pixel radius by design (see its dispatch in
     process()), scaled down by preview_scale, which never exceeds 1, so the
     unscaled radius is the bound. It must not be converted through pixel_um:
     that would scale the pad by SF_GRAIN_REF_UM/pixel_um, a factor below 1
     whenever the full-resolution long edge is under
     film_format_mm*1000/SF_GRAIN_REF_UM pixels -- 3600 px at the 36 mm
     default. The acutance recovery that follows it is bounded the same way.

     The per-sub-layer dye-cloud blur inside the sampler stays uncovered. Its
     sigma comes from a sub-layer's per-particle optical density, which only
     exists once the simulation is built, and the simulation is not available
     at ROI time. Bounding it with a constant would mean either capping a blur
     the reference does not cap -- changing rendered grain on every stock -- or
     inventing a ceiling with no data behind it, so it goes unpadded. Covering
     it would need the sigma plumbed out of the simulation and made available
     before the simulation is built. */
  const float grain = (p->grain_on && p->grain_amount > 0.0f)
                          ? fmaxf(p->grain_blur_base
                                      * fmaxf(p->grain_blur, SF_GRAIN_BLUR_MIN),
                                  (p->grain_usm_amount > 0.0f) ? p->grain_usm_sigma : 0.0f)
                          : 0.0f;
  /* coupler halo: gaussian core plus the widest exponential-tail component.
     Both are persisted absolutes and are handed to the simulation unchanged,
     so they are read here rather than assumed: the core reaches 60 um and the
     tail 400 um, and any fixed pair under-pads the upper half of both ranges.
     The tail term is gated on its weight, the same condition process() applies
     before dispatching it. */
  const float ctail = (p->couplers_tail_weight > 0.0f)
                          ? (float)SF_EXPTAIL_R2 * p->couplers_tail_um
                          : 0.0f;
  const float coupler = (p->couplers_amount > 0.0f)
                            ? fmaxf(p->couplers_diffusion_um, ctail) * inv_um
                            : 0.0f;
  /* scanner stage: already in pixels, so it does not go through inv_um */
  const float scan = fmaxf(p->scan_blur,
                           (p->scan_usm_amount > 0.0f) ? p->scan_usm_sigma : 0.0f);
  return fmaxf(fmaxf(fmaxf(hal, scat), fmaxf(diff, scan)), fmaxf(grain, coupler));
}

void modify_roi_in(dt_iop_module_t *self,
                   dt_dev_pixelpipe_iop_t *piece,
                   const dt_iop_roi_t *roi_out,
                   dt_iop_roi_t *roi_in)
{
  *roi_in = *roi_out;
  const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data;
  if(!d) return;
  /* film_format_mm is the format's long-edge dimension; buf_in.width alone
     is the SHORT edge for portrait-oriented images (post-orientation), so
     using it directly here would under-scale pixel_um (and every halo/grain/
     halation size derived from it) by the aspect ratio for portrait shots. */
  const float full_long_edge
    = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_out->scale, 1.0f);
  const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
  const int halo = (int)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um));
  if(halo <= 0) return;
  const int img_w = (int)roundf((float)piece->buf_in.width * roi_out->scale);
  const int img_h = (int)roundf((float)piece->buf_in.height * roi_out->scale);
  int x0 = roi_out->x - halo, y0 = roi_out->y - halo;
  int x1 = roi_out->x + roi_out->width + halo, y1 = roi_out->y + roi_out->height + halo;
  if(x0 < 0) x0 = 0;
  if(y0 < 0) y0 = 0;
  if(img_w > 0 && x1 > img_w) x1 = img_w;
  if(img_h > 0 && y1 > img_h) y1 = img_h;
  roi_in->x = x0;
  roi_in->y = y0;
  roi_in->width = x1 - x0;
  roi_in->height = y1 - y0;
}

void tiling_callback(dt_iop_module_t *self,
                     dt_dev_pixelpipe_iop_t *piece,
                     const dt_iop_roi_t *roi_in,
                     const dt_iop_roi_t *roi_out,
                     dt_develop_tiling_t *tiling)
{
  const dt_iop_spektrafilm_data_t *const d = (const dt_iop_spektrafilm_data_t *)piece->data;
  /* Guarded as in modify_roi_in(). With no piece data there are no parameters
     to size the halo from, so report a plain two-buffer, no-overlap stage:
     the tiler still gets a usable plan, and the module passes pixels through
     in that state anyway. */
  if(!d)
  {
    tiling->factor = 2.0f;
    tiling->factor_cl = 2.0f;
    tiling->maxbuf = 1.0f;
    tiling->maxbuf_cl = 1.0f;
    tiling->overhead = 0;
    tiling->overlap = 0;
    tiling->align = 1;
    return;
  }
  /* see modify_roi_in: film_format_mm is the long-edge dimension */
  const float full_long_edge
    = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
  const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
  /* Peak simultaneous buffers, in multiples of one RGBA tile.
     CPU: input + output (2.0) plus plane at 3 channels and the 1-channel
     blur scratch (1.0), a 3.0 baseline. Halation and the diffusion filter
     each add two 1-channel and two 3-channel buffers (2.0), for 5.0. From
     development on, corr at 3 channels is live too (3.75); the coupler tail
     adds 1.5 and grain up to 2.0 at the maximum sub-layer count. The stages
     run one after another, so grain sets the peak at 5.75.
     OpenCL: input + output plus five float4 buffers (plane, plane2, tmpa, acc,
     gtmp4) and two single-channel ones (plane1, gtmp1), a 7.5 baseline; the
     grain stage adds an accumulator and one buffer per sub-layer, up to 2.25.
     Understating these lets the tiler pick tiles that then fail to allocate,
     and the CPU path answers an allocation failure with a passthrough of that
     tile -- unprocessed rectangles inside an otherwise processed frame, not a
     clean skip -- so the estimate has to be an upper bound. */
  tiling->factor = 5.75f;
  tiling->factor_cl = 9.75f;
  tiling->maxbuf = 1.0f;
  tiling->maxbuf_cl = 1.0f;
  /* Constant tables uploaded once per run and independent of tile size: the
     two PCHIP table sets dominate, and they scale with SF_LUT_STEPS. */
  const int steps = SF_LUT_STEPS;
  size_t overhead = (size_t)192 * 192 * 3 * sizeof(float); /* spectral upsampling table */
  const size_t n3 = (size_t)steps * steps * steps * 3;
  const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3;
  const size_t set = (4 * n3 + 2 * m3) * sizeof(float);
  overhead += d->p.scan_film ? set : 2 * set;
  tiling->overhead = overhead;
  tiling->overlap = (unsigned)ceilf(SF_HALO_SIGMAS * _max_halo_sigma(&d->p, pixel_um));
  tiling->align = 1;
}

/* ---------------------------------------------------------------------- */
/* process                                                                */
/* ---------------------------------------------------------------------- */

/* base-10 conversions matching sf_pow10f / sf_log10f in spektrafilm.cl */
static inline float _sf_pow10f(const float x)
{
  return grain_exp2f(x * 3.321928094887362f);
}

static inline float _sf_log10f(const float x)
{
  return grain_log2f(x) * 0.3010299956639812f;
}

static void _passthrough(const float *in,
                         float *out,
                         int w,
                         int oh,
                         int ow,
                         int ox,
                         int oy)
{
  for(int y = 0; y < oh; y++)
    for(int x = 0; x < ow; x++)
    {
      const float *s = in + ((size_t)(y + oy) * w + (x + ox)) * 4;
      float *o = out + ((size_t)y * ow + x) * 4;
      o[0] = s[0];
      o[1] = s[1];
      o[2] = s[2];
      o[3] = s[3];
    }
}

/* Per-image salt for the grain and glare seeds, derived from the EXIF
   capture date and time: duplicates of an image share the same pattern, and
   it stays the same across re-imports and machines, while images taken at
   different times get different patterns. */
static uint32_t _grain_salt(const dt_dev_pixelpipe_iop_t *piece)
{
  const uint64_t t = (uint64_t)piece->pipe->image.exif_datetime_taken;
  return grain_hash(grain_hash((uint32_t)t) ^ (uint32_t)(t >> 32));
}

void process(dt_iop_module_t *self,
             dt_dev_pixelpipe_iop_t *piece,
             const void *const ivoid,
             void *const ovoid,
             const dt_iop_roi_t *const roi_in,
             const dt_iop_roi_t *const roi_out)
{
  dt_iop_spektrafilm_data_t *const d = (dt_iop_spektrafilm_data_t *)piece->data;
  /* process the FULL input ROI (expanded by modify_roi_in), then crop roi_out */
  const int w = roi_in->width, h = roi_in->height;
  const int ow = roi_out->width, oh = roi_out->height;
  const int ox = roi_out->x - roi_in->x, oy = roi_out->y - roi_in->y;
  const size_t npix = (size_t)w * h;
  const float *const in = (const float *)ivoid;
  float *const out = (float *)ovoid;

  const dt_iop_order_iccprofile_info_t *const work_profile
      = dt_ioppr_get_pipe_work_profile_info(piece->pipe);
  sf_sim_t *sim = (d && work_profile) ? _ensure_sim(d, work_profile) : NULL;
  if(!sim)
  {
    _passthrough(in, out, w, oh, ow, ox, oy);
    return;
  }

  /* physical micrometres per pixel at this pipe resolution; film_format_mm
     is the long-edge dimension, see modify_roi_in */
  const float full_long_edge
    = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
  const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
  /* Fixed pixel radii (grain clumps, the two unsharp masks, the glare veil) were
     validated at export resolution; darktable's preview pipe renders the same
     image smaller, where the same nominal radius covers much more real scene
     detail. Shrink them there, but never grow them past 1:1. */
  const float preview_scale = fminf(roi_in->scale, 1.0f);

  float *plane = dt_alloc_align_float(npix * 3);  /* raw / lograw / cmy, in place */
  float *scratch = dt_alloc_align_float(npix);    /* 1ch blur scratch */
  if(!plane || !scratch)
  {
    dt_free_align(plane);
    dt_free_align(scratch);
    _passthrough(in, out, w, oh, ow, ox, oy);
    return;
  }

  /* 1) camera exposure: work RGB -> spectral upsampling -> film raw exposure
        (includes the film-exposure EV) */
  sf_sim_expose(sim, in, plane, npix, 4, 3);
  /* negative exposure is not light: the pre-film blurs below would spread it
     as a dark halo over its neighbours, so a single outlier from upstream
     becomes a black disc the size of the diffusion or halation kernel.
     Development floors exposure at 0 per pixel anyway (sf_sim_lograw), so
     pixels no blur reaches are unchanged. Twin of the clamp in
     spektrafilm_expose. */
  DT_OMP_FOR()
  for(size_t k = 0; k < npix * 3; k++) plane[k] = fmaxf(plane[k], 0.0f);

  /* 2) pre-film spatial effects on LINEAR exposure, spektrafilm's order:
        highlight boost -> diffusion filter -> halation */
  sf_boost_highlights(plane, w, h, d->p.boost_ev, d->p.boost_range, d->p.protect_ev);
  if(d->p.diffusion_on)
    sf_diffusion_filter(plane, w, h, roi_in->x, roi_in->y, (double)pixel_um,
                        (int)d->p.diffusion_filter_family,
                        d->p.diffusion_strength, d->p.diffusion_scale, d->p.diffusion_warmth);
  if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f))
  {
    double hal_strength[3], hal_sigma_um;
    sf_sim_halation_params(sim, hal_strength, &hal_sigma_um);
    /* modify_roi_in()/tiling_callback() already padded for at most
       SF_HALATION_FIRST_SIGMA_UM (see _max_halo_sigma); clamp so a future
       pack entry larger than that can't under-pad the halo. */
    hal_sigma_um = fmin(hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM);
    /* per-film scatter PSF; clamped so a pack cannot outrun the ROI padding */
    double sc_core[3], sc_tail[3], sc_w[3];
    sf_sim_scatter_params(sim, sc_core, sc_tail, sc_w);
    for(int c = 0; c < 3; c++)
    {
      sc_core[c] = fmin(sc_core[c], (double)SF_SCATTER_CORE_CLAMP_UM);
      sc_tail[c] = fmin(sc_tail[c], (double)SF_SCATTER_TAIL_CLAMP_UM);
    }
    sf_halation(plane, w, h, roi_in->x, roi_in->y, (double)pixel_um, sc_core, sc_tail, sc_w,
                d->p.scatter_amount, d->p.scatter_scale, d->p.halation_amount,
                d->p.halation_scale, hal_strength, hal_sigma_um);
  }

  /* DIR coupler correction field, later the grain delta and unsharp-mask
     buffer; first needed in development, so it is not live during the
     pre-film spatial effects above */
  float *corr = dt_alloc_align_float(npix * 3);
  if(!corr)
  {
    dt_free_align(plane);
    dt_free_align(scratch);
    _passthrough(in, out, w, oh, ow, ox, oy);
    return;
  }

  /* 3) film development: log exposure, DIR coupler inhibition (the correction
        field diffuses in the emulsion: gaussian, sigma 20 um as in the
        reference), density curves */
  sf_sim_lograw(sim, plane, npix, 3);
  const int couplers = (d->p.couplers_amount > 0.0f);
  if(couplers)
  {
    sf_sim_develop_corr(sim, plane, corr, npix, 3);
    double cdiff_um, ctail_um, ctail_w;
    sf_sim_coupler_diffusion(sim, &cdiff_um, &ctail_um, &ctail_w);
    const float csigma = (float)cdiff_um / fmaxf(pixel_um, 1e-3f);
    if(ctail_w > 0.0)
    {
      /* corr = (1-w)*gauss(corr) + w*exptail(corr); exptail is upstream's
         3-gaussian mixture surrogate (fast_exponential_filter, n=3) */
      const float amp[3] = { SF_EXPTAIL_A0, SF_EXPTAIL_A1, SF_EXPTAIL_A2 };
      const float rat[3] = { SF_EXPTAIL_R0, SF_EXPTAIL_R1, SF_EXPTAIL_R2 };
      const float tail_px = (float)ctail_um / fmaxf(pixel_um, 1e-3f);
      float *mix = dt_alloc_align_float(npix * 3);
      float *tmp = dt_alloc_align_float(npix * 3);
      if(mix && tmp)
      {
        const float wbase = 1.0f - (float)ctail_w;
        memcpy(tmp, corr, sizeof(float) * npix * 3);
        if(csigma > 0.1f) sf_blur_plane3_wide(tmp, w, h, roi_in->x, roi_in->y, csigma, scratch);
        for(size_t i = 0; i < npix * 3; i++) mix[i] = wbase * tmp[i];
        for(int g3 = 0; g3 < 3; g3++)
        {
          memcpy(tmp, corr, sizeof(float) * npix * 3);
          const float ts = rat[g3] * tail_px;
          if(ts > 0.1f) sf_blur_plane3_wide(tmp, w, h, roi_in->x, roi_in->y, ts, scratch);
          const float wk = (float)ctail_w * amp[g3];
          /* fma-pinned, matching spektrafilm_diffusion_accum */
          for(size_t i = 0; i < npix * 3; i++) mix[i] = fmaf(wk, tmp[i], mix[i]);
        }
        dt_free_align(corr);
        corr = mix;
        mix = NULL;
      }
      else if(csigma > 0.1f)
        sf_blur_plane3_wide(corr, w, h, roi_in->x, roi_in->y, csigma, scratch); /* alloc failed: core only */
      dt_free_align(mix);
      dt_free_align(tmp);
    }
    else if(csigma > 0.1f)
      sf_blur_plane3_wide(corr, w, h, roi_in->x, roi_in->y, csigma, scratch);
  }
  sf_sim_develop(sim, plane, couplers ? corr : NULL, plane, npix, 3, 3);

  /* 4) grain on the developed CMY film density: sample a grained density,
        take its difference from the clean one, scale by strength, add it
        back, then clump-blur the combined field and recover acutance with
        the multiplicative unsharp mask (upstream's blur/usm pair) */
  if(d->p.grain_on && d->p.grain_amount > 0.0f)
  {
    float *gbuf = corr; /* corr is free now — reuse as the grain delta buffer */
    const int roi_x = roi_in->x, roi_y = roi_in->y;
    const uint32_t salt = _grain_salt(piece);
    const float amount = d->p.grain_amount;
    const int mono = sf_sim_film_bw(sim); /* B&W: achromatic grain */
    /* sf_grain_delta_ml's layer_npart is precomputed at sf_sim_build time
       against the fixed SF_GRAIN_REF_UM reference scale (it depends on
       curve/coupler state baked in at build time, not just resolution);
       rescale it live to the real pixel_um here. */
    const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM);
    /* grain_blur_base/SF_GRAIN_DYE_BLUR_UM/grain_usm_sigma are fixed
       pixel radii, validated against upstream at whatever single
       resolution each of its own renders happens to use -- upstream has
       no notion of "the same image, but at a temporarily reduced preview
       resolution for interactive editing speed" the way darktable's
       preview pipe does. Without this, the SAME nominal pixel radius
       covers a much larger fraction of real scene detail in a
       downscaled preview than at full/export resolution (a real image
       edge that spans ~30px at full res might span ~4px in a heavily
       zoomed-out preview), producing visible over-sharpening ringing on
       actual scene content, not just grain texture, that isn't present
       at 1:1/export. Capped at 1.0 so zooming in PAST 100% doesn't grow
       the radii beyond what was actually validated. Particle density
       (npart_scale above) is NOT touched by this -- it's correctly
       resolution-dependent via pixel_um already, confirmed against the
       reference at multiple different resolutions. */
    float grms[3], gunif[3], gdmin[3];
    /* gdmin here is what the SAMPLER adds -- the sum of the per-sub-layer
       floors, not the film's single density_min. The two coincide for a
       single-layer stock and differ for a multi-sub-layer one, where
       density_min would leave the delta with a constant positive mean. */
    sf_sim_grain_dmin_total(sim, gdmin);
    float gdmin_unused[3];
    sf_sim_film_grain3(sim, grms, gunif, gdmin_unused); /* per-film catalogue grain
                                      (rms-granularity, uniformity, density
                                      floor), read per stock */
    sf_grain_layers_t layers;
    sf_sim_grain_layers(sim, &layers); /* n==1 for a single-layer curve fit
                                      is already valid data (see the
                                      function's own comment): every stock
                                      goes through this one mechanism. */
    const int nsub = layers.n;

    /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's
       blur_particle, grain.py) runs INSIDE the particle sampler, on each
       sub-layer's raw draw independently, before the main clump blur
       below ever sees it. Its sigma depends only on that sub-layer's own
       per-particle optical density (dmax/npart) -- constant across the
       whole image for a given (channel, sub-layer), so compute it once here. */
    float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS];
    for(int c = 0; c < 3; c++)
      for(int sl = 0; sl < nsub; sl++)
      {
        const float npart_c = (float)layers.layer_npart[sl][c] * npart_scale;
        const float od_particle = (float)layers.layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f);
        dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * d->p.grain_dye_cloud
                           * sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale;
      }

    float *raw[SF_GRAIN_MAX_SUBLAYERS] = { 0 };
    gboolean raw_ok = TRUE;
    for(int sl = 0; sl < nsub; sl++)
    {
      raw[sl] = dt_alloc_align_float(npix);
      if(!raw[sl]) raw_ok = FALSE;
    }

    if(!raw_ok)
      memset(gbuf, 0, npix * 3 * sizeof(float)); /* allocation failed: skip grain gracefully */
    else
    {
      const int n_out_ch = mono ? 1 : 3;
      for(int oc = 0; oc < n_out_ch; oc++)
      {
        const int channel_idx = mono ? 1 : oc; /* mono uses channel 1's curve/params for the
                                                    achromatic draw, matching sf_grain_delta_ml */
        const int seed_ch = mono ? 0 : oc; /* mono seeds as sl*10 (no channel term), matching
                                               sf_grain_delta_ml's own convention exactly */
        const float unif_ch = gunif[channel_idx];
#ifdef _OPENMP
#pragma omp parallel for default(none)                                                          \
    shared(plane, raw, layers) firstprivate(w, npix, roi_x, roi_y, mono, channel_idx, seed_ch,   \
                                            unif_ch, npart_scale, nsub, salt) schedule(static) num_threads(darktable.num_openmp_threads)
#endif
        for(size_t k = 0; k < npix; k++)
        {
          const int x = (int)(k % (size_t)w), y = (int)(k / (size_t)w);
          const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2])
                                           / 3.0f
                                     : plane[k * 3 + channel_idx];
          float samp[SF_GRAIN_MAX_SUBLAYERS];
          sf_grain_raw_samples_ml(&layers, density, channel_idx, seed_ch, (uint32_t)(x + roi_x),
                                  (uint32_t)(y + roi_y), unif_ch, npart_scale, salt, samp);
          for(int sl = 0; sl < nsub; sl++) raw[sl][k] = samp[sl];
        }
        /* dye-cloud blur: each sub-layer independently, no variance
           restoration (matching upstream: layer_particle_model doesn't
           renormalize after its blur_particle pass either). */
        for(int sl = 0; sl < nsub; sl++)
          sf_blur_plane1(raw[sl], w, h, dye_sigma[channel_idx][sl], NULL, scratch);
        /* combine: sum sub-layers, subtract the density floor and the
           original clean density, scale by strength. */
#ifdef _OPENMP
#pragma omp parallel for default(none) shared(plane, gbuf, raw)                                  \
    firstprivate(npix, nsub, channel_idx, mono, oc, amount, gdmin) schedule(static) num_threads(darktable.num_openmp_threads)
#endif
        for(size_t k = 0; k < npix; k++)
        {
          float total = 0.0f;
          for(int sl = 0; sl < nsub; sl++) total += raw[sl][k];
          const float g = total - gdmin[channel_idx];
          const float density = mono ? (plane[k * 3 + 0] + plane[k * 3 + 1] + plane[k * 3 + 2])
                                           / 3.0f
                                     : plane[k * 3 + channel_idx];
          const float delta = (g - density) * amount;
          if(mono) gbuf[k * 3 + 0] = gbuf[k * 3 + 1] = gbuf[k * 3 + 2] = delta;
          else gbuf[k * 3 + oc] = delta;
        }
      }
    }
    for(int sl = 0; sl < nsub; sl++)
      if(raw[sl]) dt_free_align(raw[sl]);
    /* No DC-centring pass, because the delta is zero-mean by construction: the
       sampler draws an unbiased Poisson (spektra_core.h) and the combine above
       takes back exactly the floors the sampler added -- see
       sf_sim_grain_dmin_total(). The subtracted term must be that sum;
       grain_density_min in its place leaves a constant +(sum - density_min)
       per unit strength. */
    /* Add the still-UNBLURRED delta onto the clean density first, so the clump
       blur below runs on the grained ABSOLUTE density -- image detail and grain
       together -- which is what upstream blurs (_finalize_grain in grain.py
       smooths density_cmy_out itself, not a separate grain layer).

       Blurring only the delta and adding it to an untouched clean signal leaves
       real image detail at full sharpness, and the multiplicative unsharp mask
       further down then sharpens it anyway. That mask exists solely to recover
       the acutance this blur takes away: the two are a tuned pair
       (params_schema.py annotates the blur "optimized to go with the mult usm
       below" and the usm "optimized to go with the blur above"). Running the
       recovery half without the loss half is over-sharpening by construction,
       and reads as crunchy, over-defined edges on fine high-contrast texture.
       Softening genuine detail here is intended, not a side effect -- it is
       what the emulsion does, and what upstream's own output shows. */
#ifdef _OPENMP
#pragma omp parallel for default(none) shared(plane, gbuf) firstprivate(npix)              \
    schedule(static) num_threads(darktable.num_openmp_threads)
#endif
    for(size_t k = 0; k < npix * 3; k++) plane[k] += gbuf[k];
    /* Upstream's grain blur (GrainParams.blur, params_schema.py) is a
       literal FIXED pixel sigma (0.8), independent of pixel_um/resolution/
       film_format_mm -- confirmed empirically: measuring the real
       reference's noise autocorrelation at two different resolutions
       (87.5 and 35 um/px) gave near-identical radial profiles. Physical
       scaling lives entirely in particle DENSITY, which is pixel_um-driven
       above, and not in this smoothing pass. preview_scale is a SEPARATE,
       darktable-only correction (see its own comment above): upstream renders
       one real resolution, but darktable's preview pipe renders the same
       image at a temporarily reduced resolution for interactive speed,
       so this fixed radius needs shrinking there or it over-affects real
       scene detail relative to what 1:1/export shows. */
    const float sigma = d->p.grain_blur_base * fmaxf(d->p.grain_blur, SF_GRAIN_BLUR_MIN)
                         * preview_scale;
    /* No variance-restoration renorm here -- upstream's own grain
       finalization (_finalize_grain in grain.py) has none either; it just
       blurs and lets the natural contrast reduction stand, matching real
       optical clumping. Restoring full pre-blur variance would make grain
       higher-contrast, and therefore visually coarser, than upstream at any
       matching sigma. */
    sf_blur_plane3(plane, w, h, sigma, scratch);
    /* Acutance recovery for the blur above, and only meaningful because of it:
       these two are tuned together (defaults sigma 0.7 / amount 1.5). */
    if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f)
      /* The reference's multiplicative USM (_finalize_grain in grain.py)
         runs on the ABSOLUTE density -- the grain sampler's floor is still
         present and is only removed afterwards (add_micro_structure -> blur
         -> USM -> -= density_min). The delta combine above already took the
         floor back out, so pass it back in here: without it the D/blur(D)
         ratio is ill-conditioned in the deepest shadows and the USM
         amplifies shadow noise the reference never does. */
      sf_multiplicative_unsharp_mask3(plane, w, h, d->p.grain_usm_sigma * preview_scale,
                                       d->p.grain_usm_amount, gdmin, corr, scratch);
  }

  /* 5) print exposure + development (skipped in scan-film mode) */
  if(!d->p.scan_film)
  {
    sf_sim_print_expose(sim, plane, plane, npix, 3, 3);
    sf_diffusion_plan_t pplan;
    if(d->p.print_diffusion_on
       && sf_diffusion_build_plan((int)d->p.print_diffusion_filter_family,
                                  d->p.print_diffusion_strength,
                                  d->p.print_diffusion_warmth, &pplan)
       && pplan.p_s > 0.0f)
    {
      /* the filter scatters light, so it runs on linear print exposure, as the
         reference's printing.expose does, and the result goes back to log10
         with the reference's 1e-10 floor. Same conversions as the
         spektrafilm_pow10 / spektrafilm_log10 kernels. */
      DT_OMP_FOR()
      for(size_t k = 0; k < npix * 3; k++) plane[k] = _sf_pow10f(plane[k]);
      sf_diffusion_filter(plane, w, h, roi_in->x, roi_in->y, (double)pixel_um,
                          (int)d->p.print_diffusion_filter_family,
                          d->p.print_diffusion_strength, d->p.print_diffusion_scale,
                          d->p.print_diffusion_warmth);
      DT_OMP_FOR()
      for(size_t k = 0; k < npix * 3; k++) plane[k] = _sf_log10f(fmaxf(plane[k], 0.0f) + 1e-10f);
    }
    sf_sim_print_develop(sim, plane, plane, npix, 3, 3);
  }

  /* 6) scanning: viewing light through the print/film -> XYZ, plus the viewing
        glare veil in XYZ ([gl] add_glare, skipped when the film itself is
        scanned), -> work RGB with OkLCh gamut compression. `scratch` carries
        the glare field into the scan and is free again afterwards. */
  const gboolean glare = !d->p.scan_film && d->p.glare_percent > 0.0f;
  if(glare)
    sf_glare_field(scratch, w, h, d->p.glare_percent, SF_GLARE_ROUGHNESS,
                   SF_GLARE_BLUR_PX * preview_scale, roi_in->x, roi_in->y,
                   _grain_salt(piece));
  sf_sim_scan(sim, plane, glare ? scratch : NULL, plane, npix, 3, 3);

  /* 6b) scanner optics on the scanned RGB over the full padded ROI so the crop
         below never sees an edge artifact ([sc] ScannerParams lens_blur /
         unsharp_mask). */
  if(d->p.scan_blur > 0.0f)
    sf_blur_plane3(plane, w, h, d->p.scan_blur * preview_scale, scratch);
  if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f)
    sf_unsharp_mask3(plane, w, h, d->p.scan_usm_sigma * preview_scale, d->p.scan_usm_amount,
                     corr, scratch);

#ifdef _OPENMP
#pragma omp parallel for default(none) shared(plane) firstprivate(out, in, w, ow, oh, ox, oy)      \
    schedule(static) num_threads(darktable.num_openmp_threads)
#endif
  for(int y = 0; y < oh; y++)
    for(int x = 0; x < ow; x++)
    {
      const size_t ks = (size_t)(y + oy) * w + (x + ox);
      const float *pl = plane + ks * 3;
      float *o = out + ((size_t)y * ow + x) * 4;
      o[0] = pl[0];
      o[1] = pl[1];
      o[2] = pl[2];
      o[3] = in[ks * 4 + 3];
    }

  dt_free_align(plane);
  dt_free_align(corr);
  dt_free_align(scratch);
}

#ifdef HAVE_OPENCL
/* Separable Young-van Vliet pass on the device, the GPU half of
   sf_gauss_yvv_coeffs / _sf_gauss_iir_1d. `tmp` is a scratch buffer of the same
   shape as `buf`.

   Both paths must run this same recursion: a blur of a different shape produces
   a different halo width for the same sigma, and every spatial radius in the
   module was chosen against this one. See sf_gauss_yvv_coeffs. */
static cl_int _sf_yvv_blur_cl(const int devid,
                              dt_iop_spektrafilm_global_data_t *gd,
                              cl_mem buf,
                              cl_mem tmp,
                              const int w,
                              const int h,
                              const float sigma,
                              const int ch)
{
  float b[4];
  float pass_sigma = sigma;
  const int passes = sf_gauss_iir_passes(sigma, &pass_sigma);
  sf_gauss_yvv_coeffs(pass_sigma, b);
  const int krow = (ch == 4) ? gd->kernel_yvv_row_4c : gd->kernel_yvv_row_1c;
  const int kcol = (ch == 4) ? gd->kernel_yvv_col_4c : gd->kernel_yvv_col_1c;
  /* the row kernels work on tiles of sizex x sizey items, up to 8 x 16, and
     keep sizey rows of YVV_TILE_PITCH (65) elements in local memory */
  dt_opencl_local_buffer_t locopt
    = (dt_opencl_local_buffer_t){ .xoffset = 65, .xfactor = 0,
                                  .yoffset = 0, .yfactor = 1,
                                  .cellsize = ch * sizeof(float), .overhead = 0,
                                  .sizex = 8, .sizey = 16 };
  if(dt_opencl_local_buffer_opt(devid, krow, &locopt) != CL_SUCCESS)
    return DT_OPENCL_DEFAULT_ERROR;
  const size_t row_sizes[2] = { locopt.sizex, ROUNDUP(h, locopt.sizey) };
  const size_t row_local[2] = { locopt.sizex, locopt.sizey };
  const size_t tile_bytes = (size_t)locopt.sizey * 65 * ch * sizeof(float);
  cl_int e = CL_SUCCESS;
  for(int pass = 0; pass < passes && e == CL_SUCCESS; pass++)
  {
    e = dt_opencl_enqueue_kernel_2d_local_args(devid, krow, row_sizes, row_local,
                                               CLARG(buf), CLARG(tmp),
                                               CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[2]),
                                               CLARG(b[3]), CLLOCAL(tile_bytes));
    if(e == CL_SUCCESS)
      e = dt_opencl_enqueue_kernel_2d_args(devid, kcol, w, 1, CLARG(tmp), CLARG(buf),
                                           CLARG(w), CLARG(h), CLARG(b[0]), CLARG(b[2]),
                                           CLARG(b[3]));
    /* a wide sigma runs as many cascaded passes; waiting on each one keeps
       every device submission to a single pass, well inside the driver's
       job timeout, instead of letting the whole cascade queue up as one job */
    if(e == CL_SUCCESS && passes > 1 && !dt_opencl_finish(devid))
      e = DT_OPENCL_DEFAULT_ERROR;
  }
  return e;
}

/* Device half of sf_blur_plane3_wide(): in place on a float4 w*h buffer.
   Sets *done and returns the error when sf_wide_blur_plan() decimates;
   leaves *done at 0 and does nothing otherwise, so the caller runs its own
   blur. */
static cl_int _sf_blur_wide_cl(const int devid,
                               dt_iop_spektrafilm_global_data_t *gd,
                               cl_mem buf,
                               const int w,
                               const int h,
                               const int roi_x,
                               const int roi_y,
                               const float sigma,
                               int *const done)
{
  int k, lw, lh, ox, oy;
  float sigma_low;
  *done = 0;
  if(!sf_wide_blur_plan(sigma, w, h, roi_x, roi_y, &k, &sigma_low, &lw, &lh, &ox, &oy))
    return CL_SUCCESS;
  *done = 1;
  float *inv = dt_alloc_align_float((size_t)k + 1);
  float kw[2 * SF_GAUSS_MAX_RADIUS + 1];
  const int kr = dt_gaussian_kernel_1d(sigma_low, kw, SF_GAUSS_MAX_RADIUS);
  cl_mem rows = dt_opencl_alloc_device_buffer(devid, sizeof(float) * 4 * lw * h);
  cl_mem low = dt_opencl_alloc_device_buffer(devid, sizeof(float) * 4 * lw * lh);
  cl_mem lowtmp = dt_opencl_alloc_device_buffer(devid, sizeof(float) * 4 * lw * lh);
  cl_mem inv_cl = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (k + 1));
  cl_mem kw_cl = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (2 * kr + 1));
  cl_int e = CL_MEM_OBJECT_ALLOCATION_FAILURE;
  if(inv && rows && low && lowtmp && inv_cl && kw_cl)
  {
    sf_wide_blur_inv_table(k, inv);
    const float inv2k = 1.0f / (float)(2 * k);
    e = dt_opencl_write_buffer_to_device(devid, inv, inv_cl, 0, sizeof(float) * (k + 1), TRUE);
    if(e == CL_SUCCESS)
      e = dt_opencl_write_buffer_to_device(devid, kw, kw_cl, 0, sizeof(float) * (2 * kr + 1), TRUE);
    if(e == CL_SUCCESS)
      e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_wide_rows, lw, h, CLARG(buf),
                                           CLARG(rows), CLARG(w), CLARG(h), CLARG(k), CLARG(lw),
                                           CLARG(ox));
    if(e == CL_SUCCESS)
      e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_wide_down, lw, lh, CLARG(rows),
                                           CLARG(low), CLARG(w), CLARG(h), CLARG(k), CLARG(lw),
                                           CLARG(lh), CLARG(ox), CLARG(oy), CLARG(inv_cl));
    /* exact kernel at the reduced sigma: the twin of sf_blur_plane3() on the
       CPU, rows then columns */
    if(e == CL_SUCCESS)
      e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, lw, lh, CLARG(low),
                                           CLARG(lowtmp), CLARG(lw), CLARG(lh), CLARG(kw_cl),
                                           CLARG(kr));
    if(e == CL_SUCCESS)
      e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, lw, lh, CLARG(lowtmp),
                                           CLARG(low), CLARG(lw), CLARG(lh), CLARG(kw_cl),
                                           CLARG(kr));
    if(e == CL_SUCCESS)
      e = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_wide_up, w, h, CLARG(low),
                                           CLARG(buf), CLARG(w), CLARG(h), CLARG(k), CLARG(lw),
                                           CLARG(lh), CLARG(ox), CLARG(oy), CLARG(inv2k));
  }
  dt_free_align(inv);
  if(rows) dt_opencl_release_mem_object(rows);
  if(low) dt_opencl_release_mem_object(low);
  if(lowtmp) dt_opencl_release_mem_object(lowtmp);
  if(inv_cl) dt_opencl_release_mem_object(inv_cl);
  if(kw_cl) dt_opencl_release_mem_object(kw_cl);
  return e;
}

/* GPU path: mirrors process(). Per-pixel stages run as kernels on the
   validated float tables from sf_sim_gpu_export() (POCL-checked to ~1e-6 vs
   the CPU engine); the Gaussian blurs (diffusion bank, halation bounces,
   coupler correction diffusion, grain clumps) use this file's own direct
   separable convolution (spektrafilm_gauss_row/col_*c, weights built
   host-side by dt_gaussian_kernel_1d), exactly as the
   CPU path uses sf_blur_plane3. */
int process_cl(dt_iop_module_t *self,
               dt_dev_pixelpipe_iop_t *piece,
               cl_mem dev_in,
               cl_mem dev_out,
               const dt_iop_roi_t *const roi_in,
               const dt_iop_roi_t *const roi_out)
{
  dt_iop_spektrafilm_data_t *const d = (dt_iop_spektrafilm_data_t *)piece->data;
  dt_iop_spektrafilm_global_data_t *gd = (dt_iop_spektrafilm_global_data_t *)self->global_data;
  const int devid = piece->pipe->devid;
  const int w = roi_in->width, h = roi_in->height;
  const int ow = roi_out->width, oh = roi_out->height;
  const int ox = roi_out->x - roi_in->x, oy = roi_out->y - roi_in->y;
  const size_t npix = (size_t)w * h;
  cl_int err = DT_OPENCL_DEFAULT_ERROR;
#define SF_CL_STEP(label)                                                                          \
  do                                                                                               \
  {                                                                                                \
    if(err != CL_SUCCESS)                                                                          \
    {                                                                                              \
      dt_print(DT_DEBUG_OPENCL, "[spektrafilm] GPU step FAILED: %s (err=%d)\n", (label),          \
               (int)err);                                                                          \
      goto cleanup;                                                                                \
    }                                                                                              \
  } while(0)

  /* no kernels (init_global() could not allocate) or no piece data
     (init_pipe() could not): hand the tile to the CPU path, which passes it
     through */
  if(!gd || !d) return DT_OPENCL_DEFAULT_ERROR;

  const dt_iop_order_iccprofile_info_t *const work_profile
      = dt_ioppr_get_pipe_work_profile_info(piece->pipe);
  sf_sim_t *sim = work_profile ? _ensure_sim(d, work_profile) : NULL;
  const sf_sim_gpu_t *g = d->gpu;

  if(!sim) /* no data pack / profiles: crop passthrough */
    return dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_passthrough, ow, oh,
                                            CLARG(dev_in), CLARG(dev_out), CLARG(ow),
                                            CLARG(oh), CLARG(ox), CLARG(oy));
  if(!g) return DT_OPENCL_DEFAULT_ERROR; /* no GPU tables -> CPU fallback */

  /* film_format_mm is the long-edge dimension, see modify_roi_in */
  const float full_long_edge
    = fmaxf(fmaxf((float)piece->buf_in.width, (float)piece->buf_in.height) * roi_in->scale, 1.0f);
  const float pixel_um = d->p.film_format_mm * 1000.0f / full_long_edge;
  /* see the matching comment in process(): fixed pixel radii shrink with the
     preview pipe's reduced resolution, but never grow past 1:1 */
  const float preview_scale = fminf(roi_in->scale, 1.0f);

  /* ---- table uploads (read-only buffers) -------------------------------- */
  /* packed matrix block: layout must match the SF_M_* offsets in the .cl */
  float mats[93]; /* SF_M_* layout in spektrafilm.cl */
  memcpy(mats + 0, g->m_in, 9 * sizeof(float));
  memcpy(mats + 9, g->m_out, 9 * sizeof(float));
  memcpy(mats + 18, g->couplers_M, 9 * sizeof(float));
  memcpy(mats + 27, g->out_rgb2xyz, 9 * sizeof(float));
  memcpy(mats + 36, g->out_xyz2rgb, 9 * sizeof(float));
  memcpy(mats + 45, g->oklab_m1, 9 * sizeof(float));
  memcpy(mats + 54, g->oklab_m2, 9 * sizeof(float));
  memcpy(mats + 63, g->oklab_m1inv, 9 * sizeof(float));
  memcpy(mats + 72, g->oklab_m2inv, 9 * sizeof(float));
  memcpy(mats + 81, g->couplers_donor_K, 3 * sizeof(float));
  memcpy(mats + 84, g->couplers_donor_Dref, 3 * sizeof(float));
  memcpy(mats + 87, g->couplers_recv_Kr, 3 * sizeof(float));
  memcpy(mats + 90, g->couplers_recv_cref, 3 * sizeof(float));

  const int steps = g->steps;
  const size_t n3 = (size_t)steps * steps * steps * 3;
  const size_t m3 = (size_t)(steps - 1) * (steps - 1) * (steps - 1) * 3;
  const size_t f = sizeof(float);
  cl_mem mats_cl = dt_opencl_copy_host_to_device_constant(devid, 93 * f, mats);
  cl_mem tc_cl = dt_opencl_copy_host_to_device_constant(
      devid, (size_t)g->tc_n * g->tc_n * 3 * f, g->tc_lut);
  cl_mem cn_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->curves_norm);
  cl_mem cb_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f,
                                                        g->couplers_active ? g->curves_before
                                                                           : g->curves_norm);
  cl_mem el_cl = NULL, ex_cl = NULL, ey_cl = NULL, ez_cl = NULL, en_cl = NULL, em_cl = NULL;
  cl_mem pc_cl = NULL;
  if(g->has_print)
  {
    el_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_lut);
    ex_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sx);
    ey_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sy);
    ez_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->enl_sz);
    en_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmin);
    em_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->enl_cmax);
    pc_cl = dt_opencl_copy_host_to_device_constant(devid, 256 * 3 * f, g->print_curves);
  }
  cl_mem sl_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_lut);
  cl_mem sx_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sx);
  cl_mem sy_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sy);
  cl_mem sz_cl = dt_opencl_copy_host_to_device_constant(devid, n3 * f, g->scan_sz);
  cl_mem sn_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmin);
  cl_mem sm_cl = dt_opencl_copy_host_to_device_constant(devid, m3 * f, g->scan_cmax);
  /* cmax_table is only used in oklch mode but the kernel arg must be valid */
  cl_mem cm_cl = dt_opencl_copy_host_to_device_constant(
      devid, (g->cmax_table ? (size_t)g->cmax_nl * g->cmax_nh : 1) * f,
      g->cmax_table ? (void *)g->cmax_table : (void *)mats);

  cl_mem plane = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
  cl_mem plane2 = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
  cl_mem tmpa = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
  cl_mem acc = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
  /* single-channel scratch for the scatter stage's genuinely per-channel
     blurs (spektrafilm_channel_extract + a 1ch Gaussian): 1/4 the size and
     1/4 the per-blur cost of running the equivalent work on a float4
     buffer, see the scatter stage below. */
  cl_mem plane1 = dt_opencl_alloc_device_buffer(devid, npix * f);
  /* row-pass intermediates for the direct (exact) separable convolution
     below: dedicated buffers, distinct from every buffer a blur might be
     called on in place, so the row pass never aliases its own input. */
  cl_mem gtmp4 = dt_opencl_alloc_device_buffer(devid, npix * f * 4);
  cl_mem gtmp1 = dt_opencl_alloc_device_buffer(devid, npix * f);
  /* kernel weights (2*SF_GAUSS_MAX_RADIUS+1 taps, built host-side by
     dt_gaussian_kernel_1d and rewritten before each blur dispatch below) */
  cl_mem gauss_w = dt_opencl_alloc_device_buffer(devid, sizeof(float) * (2 * SF_GAUSS_MAX_RADIUS + 1));
  if(!mats_cl || !tc_cl || !cn_cl || !cb_cl || !sl_cl || !sx_cl || !sy_cl || !sz_cl || !sn_cl
     || !sm_cl || !cm_cl || !plane || !plane2 || !tmpa || !acc || !plane1 || !gtmp4 || !gtmp1
     || !gauss_w
     || (g->has_print && (!el_cl || !ex_cl || !ey_cl || !ez_cl || !en_cl || !em_cl || !pc_cl)))
  {
    err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
    goto cleanup;
  }
/* Direct (exact) separable Gaussian blur: builds the exact kernel
   host-side (the same dt_gaussian_kernel_1d() the CPU path convolves with),
   uploads it, then dispatches a row pass into a dedicated scratch buffer
   followed by a col pass into `dst` -- safe even when dst==buf (in-place),
   since the row pass fully consumes buf into scratch before the col pass
   writes buf. No sigma-correction factor: unlike a recursive/IIR
   approximation, a direct truncated kernel has no sigma-dependent error to
   correct for in the first place. */
#define SF_GAUSS_BLUR4(buf, _sg, label) do { \
    if(err == CL_SUCCESS) \
    { \
      float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
      const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
      err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
                                             sizeof(float) * (2 * _kr + 1), TRUE); \
      if(err == CL_SUCCESS) \
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
                                               CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \
                                               CLARG(gauss_w), CLARG(_kr)); \
      if(err == CL_SUCCESS) \
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
                                               CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \
                                               CLARG(gauss_w), CLARG(_kr)); \
    } \
    SF_CL_STEP(label); \
  } while(0)
/* Same as SF_GAUSS_BLUR4, but above SF_GAUSS_EXACT_MAX_SIGMA falls back to
   the shared Young-van Vliet recursion (_sf_yvv_blur_cl above)
   instead of the exact row/col kernels: for callers with no downstream
   dependency on the exact kernel's shape (unlike grain's SF_GAUSS_BLUR4
   above, which stays on the exact path unconditionally -- the recursive
   approximation's ~18% effective-width error would make grain the wrong
   size relative to upstream's own exact-shape Gaussian), this recovers most
   of the O(radius) cost the exact kernel pays at large sigma. */
#define SF_GAUSS_BLUR4_FAST(buf, _sg, label) do { \
    if(err == CL_SUCCESS) \
    { \
      if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
        err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp4, w, h, (_sg), 4); \
      else \
      { \
        float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
        const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
        err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
                                               sizeof(float) * (2 * _kr + 1), TRUE); \
        if(err == CL_SUCCESS) \
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
                                                 CLARG(buf), CLARG(gtmp4), CLARG(w), CLARG(h), \
                                                 CLARG(gauss_w), CLARG(_kr)); \
        if(err == CL_SUCCESS) \
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
                                                 CLARG(gtmp4), CLARG(buf), CLARG(w), CLARG(h), \
                                                 CLARG(gauss_w), CLARG(_kr)); \
      } \
    } \
    SF_CL_STEP(label); \
  } while(0)
/* loop-safe variant: sets err, caller checks err/breaks; src/dst may differ
   (e.g. accumulating several blurred copies of the same source). Falls back
   to the fast recursive blur above SF_GAUSS_EXACT_MAX_SIGMA, same rationale
   as SF_GAUSS_BLUR4_FAST -- none of this macro's callers (halation bounce,
   coupler tail, both diffusion filters) renormalize against the exact
   kernel's shape. */
#define SF_GAUSS_BLUR4_OP_L(src, dst, _sg) do { \
    if(err == CL_SUCCESS) \
    { \
      if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
      { \
        err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \
        if(err == CL_SUCCESS) \
          err = _sf_yvv_blur_cl(devid, gd, (dst), gtmp4, w, h, (_sg), 4); \
      } \
      else \
      { \
        float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
        const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
        err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
                                               sizeof(float) * (2 * _kr + 1), TRUE); \
        if(err == CL_SUCCESS) \
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_4c, w, h, \
                                                 CLARG(src), CLARG(gtmp4), CLARG(w), CLARG(h), \
                                                 CLARG(gauss_w), CLARG(_kr)); \
        if(err == CL_SUCCESS) \
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_4c, w, h, \
                                                 CLARG(gtmp4), CLARG(dst), CLARG(w), CLARG(h), \
                                                 CLARG(gauss_w), CLARG(_kr)); \
      } \
    } \
  } while(0)
/* SF_GAUSS_BLUR4_OP_L, decimated from SF_WIDE_BLUR_EFFECT_SIGMA up: the
   device twin of sf_diffusion_filter() and sf_halation()'s bounces, which call
   sf_blur_plane3_wide() from the same threshold. */
#define SF_BLUR4_WIDE_OP_L(src, dst, _sg) do { \
    const float _ws = (_sg); \
    if(err == CL_SUCCESS && _ws >= SF_WIDE_BLUR_EFFECT_SIGMA) \
    { \
      int _wide = 0; \
      err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, (src), (dst), 0, 0, npix * f * 4); \
      if(err == CL_SUCCESS) \
        err = _sf_blur_wide_cl(devid, gd, (dst), w, h, roi_in->x, roi_in->y, _ws, &_wide); \
    } \
    else \
      SF_GAUSS_BLUR4_OP_L(src, dst, _ws); \
  } while(0)
/* single-channel in-place blur (scatter stage only, on plane1). Same fast
   fallback above SF_GAUSS_EXACT_MAX_SIGMA -- scatter's core/tail sigmas are
   normally small (sub-few-px), but the fallback is here for whatever a user's
   scatter_scale slider can push them to. */
/* Exact-kernel-only single-channel blur, the GPU twin of sf_blur_plane1():
   that function passes exact_only=1 unconditionally, so its call sites must
   NOT fall back to the recursive approximation at large sigma the way
   SF_GAUSS_BLUR1_L below does. Used by the grain dye-cloud blur. */
#define SF_GAUSS_BLUR1_EXACT(buf, _sg) do { \
    if(err == CL_SUCCESS) \
    { \
      float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
      const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
      err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
                                             sizeof(float) * (2 * _kr + 1), TRUE); \
      if(err == CL_SUCCESS) \
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \
                                               CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \
                                               CLARG(gauss_w), CLARG(_kr)); \
      if(err == CL_SUCCESS) \
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \
                                               CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \
                                               CLARG(gauss_w), CLARG(_kr)); \
    } \
  } while(0)
#define SF_GAUSS_BLUR1_L(buf, _sg) do { \
    if(err == CL_SUCCESS) \
    { \
      if((_sg) >= SF_GAUSS_EXACT_MAX_SIGMA) \
        err = _sf_yvv_blur_cl(devid, gd, (buf), gtmp1, w, h, (_sg), 1); \
      else \
      { \
        float _kw[2 * SF_GAUSS_MAX_RADIUS + 1]; \
        const int _kr = dt_gaussian_kernel_1d((_sg), _kw, SF_GAUSS_MAX_RADIUS); \
        err = dt_opencl_write_buffer_to_device(devid, _kw, gauss_w, 0, \
                                               sizeof(float) * (2 * _kr + 1), TRUE); \
        if(err == CL_SUCCESS) \
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_row_1c, w, h, \
                                                 CLARG(buf), CLARG(gtmp1), CLARG(w), CLARG(h), \
                                                 CLARG(gauss_w), CLARG(_kr)); \
        if(err == CL_SUCCESS) \
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_gauss_col_1c, w, h, \
                                                 CLARG(gtmp1), CLARG(buf), CLARG(w), CLARG(h), \
                                                 CLARG(gauss_w), CLARG(_kr)); \
      } \
    } \
  } while(0)

  /* ---- 1) expose: input image -> linear film raw exposure ---------------- */
  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_expose, w, h, CLARG(dev_in),
                                         CLARG(plane), CLARG(w), CLARG(h), CLARG(mats_cl),
                                         CLARG(tc_cl), CLARG(g->tc_n), CLARG(g->ev_scale));
  SF_CL_STEP("expose");

  /* ---- 2) pre-film spatial effects on linear exposure -------------------- */
  {
    /* The curve is anchored to the exposure scale, not to a frame maximum, so
       the boost agrees between the preview pipe, the export pipe and every
       tile. A frame maximum would differ per ROI. */
    sf_boost_plan_t bp;
    if(sf_boost_build_plan(d->p.boost_ev, d->p.boost_range, d->p.protect_ev, &bp))
    {
      err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_boost, w, h, CLARG(plane),
                                             CLARG(w), CLARG(h), CLARG(bp.raw_x0),
                                             CLARG(bp.xmax), CLARG(bp.inv_max), CLARG(bp.a),
                                             CLARG(bp.a_log2e), CLARG(bp.scale),
                                             CLARG(bp.ext), CLARG(bp.slope));
      SF_CL_STEP("boost");
    }
  }

  if(d->p.diffusion_on)
  {
    sf_diffusion_plan_t plan;
    if(sf_diffusion_build_plan((int)d->p.diffusion_filter_family, d->p.diffusion_strength,
                               d->p.diffusion_warmth, &plan)
       && plan.p_s > 0.0f)
    {
      /* double, and no 1e-3f floor: sf_diffusion_filter() computes
         (float)(sigma_um * sc / fmax(pixel_um, 1e-3)) with sc and the divide
         both in double and no clamp on the result. In float the value differs
         in the last places, which is a different kernel out of
         dt_gaussian_kernel_1d() and so a different blur on every pixel the
         filter touches. */
      const double dsc = fmax((double)d->p.diffusion_scale, 1e-6);
      for(int j = 0; j < plan.n; j++)
      {
        const float sigma
            = (float)((double)plan.sigma_um[j] * dsc / fmax((double)pixel_um, 1e-3));
        SF_BLUR4_WIDE_OP_L(plane, tmpa, sigma);
        if(err != CL_SUCCESS) break;
        const int reset = (j == 0);
        const float wr = plan.wr[j], wg = plan.wg[j], wb = plan.wb[j];
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
                                               CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h),
                                               CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset));
        if(err != CL_SUCCESS) break;
      }
      if(err == CL_SUCCESS)
      {
        const float ps = plan.p_s;
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h,
                                               CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
                                               CLARG(ps));
      }
      SF_CL_STEP("diffusion");
    }
  }

  /* ---- sigma parity with the CPU ------------------------------------------
     Every blur sigma below is derived from the SAME doubles, by the SAME
     expression in the SAME association, at the SAME precision, with the SAME
     floor as its twin in process()/spektra_core.c.

     Both halves of that matter. Reading the float mirrors in sf_sim_gpu_t is
     already wrong -- (double)(float)x != x -- so these call the sim accessors
     directly, exactly as process() does, and only round at the end.

     This is not pedantry. Above SF_GAUSS_EXACT_MAX_SIGMA both paths leave the
     truncated kernel for the Young-van Vliet recursion. sf_gauss_yvv_coeffs()
     maps sigma continuously onto B/B1/B2/B3, and an IIR response depends
     globally on those: the forward pass carries any perturbation to the end of
     every line, the backward pass carries it back, and the column pass spreads
     it down every column it touched. So a last-place difference in sigma --
     from float instead of double, from a*b*c/d instead of a*b/d*c, or from a
     1e-3f floor instead of 1e-6f -- stops being a rounding difference and
     becomes a different filter over the whole image. Below the threshold the
     same mismatch is invisible, which is why the divergence this guards
     against is sigma-dependent rather than uniform.
     -------------------------------------------------------------------- */
  double cl_hal_strength[3], cl_hal_sigma_um = 0.0;
  double cl_sc_core[3], cl_sc_tail[3], cl_sc_w[3];
  if(d->p.halation_on)
  {
    sf_sim_halation_params(sim, cl_hal_strength, &cl_hal_sigma_um);
    cl_hal_sigma_um = fmin(cl_hal_sigma_um, (double)SF_HALATION_FIRST_SIGMA_UM);
    sf_sim_scatter_params(sim, cl_sc_core, cl_sc_tail, cl_sc_w);
    for(int c = 0; c < 3; c++)
    {
      cl_sc_core[c] = fmin(cl_sc_core[c], (double)SF_SCATTER_CORE_CLAMP_UM);
      cl_sc_tail[c] = fmin(cl_sc_tail[c], (double)SF_SCATTER_TAIL_CLAMP_UM);
    }
  }
  if(d->p.halation_on && (d->p.scatter_amount > 0.0f || d->p.halation_amount > 0.0f))
  {
    if(d->p.scatter_amount > 0.0f)
    {
      const float sscl = fmaxf(d->p.scatter_scale, 1e-3f);
      /* per-channel scatter radii (um on film) and tail mixture, identical to
         spektra_core.c's sf_halation() sc_core/sc_tail/tail_amp/tail_rat.
         Each channel needs its OWN sigma (R/G/B differ): extract that
         channel into the single-channel scratch buffer plane1, blur it
         alone (1x the work of a same-size float4 blur, not 4x), then
         kernel_channel_accum folds it into the target channel of tmpa/acc. */
      /* per-film scatter PSF: cl_sc_core/cl_sc_tail above, clamped in double
         from the sim's own values -- not fminf() on sf_sim_gpu_t's float
         mirrors, which rounds once more than the CPU does. */
      const float amp[3] = { 0.1633f, 0.6496f, 0.1870f }, rat[3] = { 0.5360f, 1.5236f, 2.7684f };
      for(int c = 0; c < 3; c++)
      {
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h,
                                               CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h),
                                               CLARG(c));
        if(err != CL_SUCCESS) break;
        /* CPU: fmaxf((float)(sc_core[c] * scl / pixel_um), 1e-6f) */
        SF_GAUSS_BLUR1_L(plane1, fmaxf((float)(cl_sc_core[c] * (double)sscl
                                               / (double)pixel_um), 1e-6f));
        if(err != CL_SUCCESS) break;
        const float core_weight = 1.0f;
        const int core_reset = (c == 0);
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h,
                                               CLARG(plane1), CLARG(tmpa), CLARG(w), CLARG(h),
                                               CLARG(core_weight), CLARG(c), CLARG(core_reset));
        SF_CL_STEP("scatter core blur");
      }
      for(int g3 = 0; g3 < 3 && err == CL_SUCCESS; g3++)
        for(int c = 0; c < 3; c++)
        {
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_extract, w, h,
                                                 CLARG(plane), CLARG(plane1), CLARG(w), CLARG(h),
                                                 CLARG(c));
          if(err != CL_SUCCESS) break;
          /* CPU associates as rat * (tail * scl / pixel_um), in double */
          const float sigma = fmaxf((float)((double)rat[g3]
                                            * (cl_sc_tail[c] * (double)sscl
                                               / (double)pixel_um)), 1e-6f);
          SF_GAUSS_BLUR1_L(plane1, sigma);
          if(err != CL_SUCCESS) break;
          const int reset = (g3 == 0 && c == 0);
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_channel_accum, w, h,
                                                 CLARG(plane1), CLARG(acc), CLARG(w), CLARG(h),
                                                 CLARG(amp[g3]), CLARG(c), CLARG(reset));
          SF_CL_STEP("scatter tail accum");
        }
      /* CPU: sf_halation() rounds the sim's double w_s[] to float once and
         blends with the same fma nesting as spektrafilm_scatter_combine.
         Rounded from the same double here, not read from sf_sim_gpu_t's
         float mirror, so the constant the kernel gets is the CPU's. */
      const float ws_r = (float)cl_sc_w[0], ws_g = (float)cl_sc_w[1],
                  ws_b = (float)cl_sc_w[2];
      /* (1-s)*raw + s*scattered, matching sf_halation()'s CPU blend; `plane`
         doubles as both the pre-scatter `raw` input and the `out` write
         target -- safe since this is a purely per-pixel elementwise op. */
      /* convex blend weight -- see sf_halation() for why it cannot exceed 1 */
      const float s_amount = CLAMP(d->p.scatter_amount, 0.0f, 1.0f);
      err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scatter_combine, w, h, CLARG(plane),
                                             CLARG(tmpa), CLARG(acc), CLARG(plane), CLARG(w),
                                             CLARG(h), CLARG(s_amount), CLARG(ws_r), CLARG(ws_g),
                                             CLARG(ws_b));
      SF_CL_STEP("scatter combine");
    }

    if(d->p.halation_amount > 0.0f)
    {
      const float hscl = fmaxf(d->p.halation_scale, 1e-3f);
      const int N = 3;
      /* per-film first-bounce radius (still ~65um / cine ~50um on real
         stocks); clamped to what modify_roi_in()/tiling_callback() padded
         for, see the matching comment in process(). */
      /* clamped in double above, as process() clamps hal_sigma_um */
      const float dec[3] = { 1.0f/1.75f, 0.5f/1.75f, 0.25f/1.75f };
      for(int k = 1; k <= N; k++)
      {
        /* CPU: fmaxf((float)((first_sigma_um * hscl / pixel_um) * sqrt((double)k)), 1e-6f).
           Five things have to match on this line, and each is easy to lose:
           the sim's double rather than sf_sim_gpu_t's float mirror, double
           arithmetic throughout, sqrt() and not sqrtf(), the divide before the
           sqrt factor, and the CPU's 1e-6f floor. */
        SF_BLUR4_WIDE_OP_L(plane, plane2,
                           fmaxf((float)((cl_hal_sigma_um * (double)hscl
                                          / (double)pixel_um) * sqrt((double)k)), 1e-6f));
        if(err != CL_SUCCESS) break;
        const int reset = (k == 1);
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_accum, w, h, CLARG(plane2),
                                               CLARG(acc), CLARG(w), CLARG(h), CLARG(dec[k - 1]),
                                               CLARG(reset));
        SF_CL_STEP("halation bounce accum");
      }
      /* halation_amount is a direct linear multiplier on strength, matching
         upstream's a_tot = halation_strength * halation_amount (no curve). */
      const float h_eff = d->p.halation_amount;
      /* per-film halation strength (e.g. a strong-AH stock stays near-zero on
         blue and much lower on red/green than a no-AH/redscale stock).

         CPU: a_tot[c] = halation_strength[c] * halation_amount in double
         (sf_halation()), rounded once to float for the per-pixel
         fma(a, blur, raw) / (1 + a). Formed the same way here from
         cl_hal_strength -- the sim's own double, as the sigma above is. */
      const float a_r = (float)(cl_hal_strength[0] * (double)h_eff),
                  a_g = (float)(cl_hal_strength[1] * (double)h_eff),
                  a_b = (float)(cl_hal_strength[2] * (double)h_eff);
      err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_halation_apply, w, h, CLARG(plane),
                                             CLARG(acc), CLARG(w), CLARG(h), CLARG(a_r),
                                             CLARG(a_g), CLARG(a_b));
      SF_CL_STEP("halation apply");
    }
  }

  /* ---- 3) film development ------------------------------------------------ */
  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_lograw, w, h, CLARG(plane), CLARG(w),
                                         CLARG(h), CLARG(g->raw_scale));
  SF_CL_STEP("lograw");

  const int use_corr = g->couplers_active;
  if(use_corr)
  {
    err = dt_opencl_enqueue_kernel_2d_args(
        devid, gd->kernel_develop_corr, w, h, CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
        CLARG(cn_cl), CLARG(mats_cl),
        CLARG(g->le0), CLARG(g->inv_le_step), CLARG(g->film_dmax[0]), CLARG(g->film_dmax[1]),
        CLARG(g->film_dmax[2]), CLARG(g->film_positive), CLARG(g->couplers_donor_lm));
    SF_CL_STEP("develop_corr");
    /* DIR coupler inhibitor diffusion, gaussian sigma 20 um (reference value) */
    const float csigma = g->coupler_diff_um / fmaxf(pixel_um, 1e-3f);
    if(g->coupler_tail_w > 0.0f)
    {
      /* float operands throughout, as process() forms them: amp/rat are float
         arrays there, so each product is a float multiply, not a double one
         rounded afterwards. tail_px is ctail_um / pixel_um once, then
         rat[g3] * tail_px -- R * (tail / pixel), not (R * tail) / pixel. */
      const float ea[3] = { SF_EXPTAIL_A0, SF_EXPTAIL_A1, SF_EXPTAIL_A2 };
      const float er[3] = { SF_EXPTAIL_R0, SF_EXPTAIL_R1, SF_EXPTAIL_R2 };
      const float amp[4] = { 1.0f - g->coupler_tail_w, g->coupler_tail_w * ea[0],
                             g->coupler_tail_w * ea[1], g->coupler_tail_w * ea[2] };
      const float tail_px = g->coupler_tail_um / fmaxf(pixel_um, 1e-3f);
      const float sig[4] = { csigma, er[0] * tail_px, er[1] * tail_px, er[2] * tail_px };
      for(int g3 = 0; g3 < 4; g3++)
      {
        /* >= SF_GAUSS_MIN_SIGMA, not > 0.1f: the CPU twin guards with
           `if(ts > 0.1f) sf_blur_plane3_wide(...)`, which decimates from
           SF_WIDE_BLUR_MIN_SIGMA up and below that hands over to
           sf_blur_plane3_fast, which returns without touching the buffer below
           SF_GAUSS_MIN_SIGMA -- so the CPU blurs iff the sigma clears 0.3.
           Between 0.1 and 0.3 it copies the unblurred correction, which is
           what the else branch below does. */
        if(sig[g3] >= SF_WIDE_BLUR_MIN_SIGMA)
        {
          int wide = 0;
          err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, plane2, 0, 0, npix * f * 4);
          if(err == CL_SUCCESS)
            err = _sf_blur_wide_cl(devid, gd, plane2, w, h, roi_in->x, roi_in->y, sig[g3], &wide);
          if(err != CL_SUCCESS) break;
        }
        else if(sig[g3] >= SF_GAUSS_MIN_SIGMA)
        {
          SF_GAUSS_BLUR4_OP_L(acc, plane2, sig[g3]);
          if(err != CL_SUCCESS) break;
        }
        else
        {
          err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, acc, plane2, 0, 0, npix * f * 4);
          if(err != CL_SUCCESS) break;
        }
        const int reset = (g3 == 0);
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
                                               CLARG(plane2), CLARG(tmpa), CLARG(w), CLARG(h),
                                               CLARG(amp[g3]), CLARG(amp[g3]), CLARG(amp[g3]),
                                               CLARG(reset));
        SF_CL_STEP("coupler tail accum");
      }
    }
    else if(csigma > 0.1f)
    {
      /* CPU twin is sf_blur_plane3_wide(): decimated from
         SF_WIDE_BLUR_MIN_SIGMA up, sf_blur_plane3_fast below, which returns
         without touching the buffer below SF_GAUSS_MIN_SIGMA -- match both
         cuts here. */
      if(csigma >= SF_WIDE_BLUR_MIN_SIGMA)
      {
        int wide = 0;
        err = _sf_blur_wide_cl(devid, gd, acc, w, h, roi_in->x, roi_in->y, csigma, &wide);
        SF_CL_STEP("coupler blur");
      }
      else if(csigma >= SF_GAUSS_MIN_SIGMA)
        SF_GAUSS_BLUR4_FAST(acc, csigma, "coupler blur");
    }
  }
  cl_mem corr_buf = (g->coupler_tail_w > 0.0f) ? tmpa : acc;
  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_develop, w, h, CLARG(plane),
                                         CLARG(corr_buf), CLARG(use_corr), CLARG(plane2), CLARG(w),
                                         CLARG(h), CLARG(cb_cl), CLARG(mats_cl), CLARG(g->le0),
                                         CLARG(g->inv_le_step), CLARG(g->couplers_recv_lm));
  SF_CL_STEP("develop");

  /* ---- 4) grain on the developed CMY density ----------------------------- */
  if(d->p.grain_on && d->p.grain_amount > 0.0f)
  {
    const int roi_x = roi_in->x, roi_y = roi_in->y;
    const uint32_t salt = _grain_salt(piece);
    const float amount = d->p.grain_amount;
    const int mono = g->film_bw; /* B&W: achromatic grain */
    /* see the matching comment on the CPU path (process()) for the full
       rationale: darktable's preview pipe renders at a temporarily
       reduced resolution, unlike upstream which always renders one real
       resolution, so these fixed pixel radii need shrinking there to
       avoid over-affecting real scene detail. Capped at 1.0 so zooming
       in past 100% doesn't grow radii beyond what was validated. Does
       NOT apply to npart_scale below, which is correctly resolution-
       dependent via pixel_um already. */
    /* Unified through the multi-sublayer table for every stock (nsub can be
       1); there is no separate single-layer kernel -- see the matching comment
       in process()'s CPU path for why that is valid: the build-time layer
       table already carries correct n==1 data for single-layer stocks.
       Uploaded once per d->gpu (see d->grain_cl_built_for above) rather than
       once per process_cl() call: tiled processing calls this once per tile,
       and the tables are identical across every tile of the same image. */
    const int nsub = g->grain_n_sublayers, nle = SF_NLE, maxsub = SF_GRAIN_MAX_SUBLAYERS;
    if(d->grain_cl_built_for != g || d->grain_cl_devid != devid)
    {
      if(d->grain_cl_dmax) dt_opencl_release_mem_object(d->grain_cl_dmax);
      if(d->grain_cl_npart) dt_opencl_release_mem_object(d->grain_cl_npart);
      if(d->grain_cl_dmin) dt_opencl_release_mem_object(d->grain_cl_dmin);
      if(d->grain_cl_total) dt_opencl_release_mem_object(d->grain_cl_total);
      if(d->grain_cl_curve) dt_opencl_release_mem_object(d->grain_cl_curve);
      d->grain_cl_dmax = dt_opencl_copy_host_to_device_constant(
          devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmax);
      d->grain_cl_npart = dt_opencl_copy_host_to_device_constant(
          devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_npart);
      d->grain_cl_dmin = dt_opencl_copy_host_to_device_constant(
          devid, (size_t)maxsub * 3 * f, (void *)g->grain_layer_dmin);
      d->grain_cl_total = dt_opencl_copy_host_to_device_constant(
          devid, (size_t)nle * 3 * f, (void *)g->grain_layer_curve_total);
      d->grain_cl_curve = dt_opencl_copy_host_to_device_constant(
          devid, (size_t)nle * maxsub * 3 * f, (void *)g->grain_layer_curve);
      /* Claim the cache only once every upload landed. If the key were
         recorded before this test, a partial failure would be permanent for
         this simulation and device: the guard above would skip the rebuild, the
         check below would fail on the same NULL, and the OpenCL path would stay
         unavailable until the simulation itself was rebuilt. */
      if(d->grain_cl_dmax && d->grain_cl_npart && d->grain_cl_dmin && d->grain_cl_total
         && d->grain_cl_curve)
      {
        d->grain_cl_built_for = g;
        d->grain_cl_devid = devid;
      }
    }
    if(!d->grain_cl_dmax || !d->grain_cl_npart || !d->grain_cl_dmin || !d->grain_cl_total
       || !d->grain_cl_curve)
      err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
    else
    {
      /* layer_npart (uploaded above) is precomputed at sf_sim_build time
         against the fixed SF_GRAIN_REF_UM reference scale; rescale it live
         to the real pixel_um here. */
      const float npart_scale = (pixel_um * pixel_um) / (SF_GRAIN_REF_UM * SF_GRAIN_REF_UM);
      /* Upstream's per-sub-layer dye-cloud blur (layer_particle_model's
         blur_particle, grain.py): sigma depends only on that sub-layer's
         own per-particle optical density (dmax/npart), which is constant
         across the whole image -- computed host-side once here, same as the CPU path, not per-pixel
         on the device. */
      float dye_sigma[3][SF_GRAIN_MAX_SUBLAYERS];
      for(int c = 0; c < 3; c++)
        for(int sl = 0; sl < nsub; sl++)
        {
          const float npart_c = g->grain_layer_npart[sl][c] * npart_scale;
          const float od_particle = g->grain_layer_dmax[sl][c] / fmaxf(npart_c, 1e-6f);
          dye_sigma[c][sl] = SF_GRAIN_DYE_BLUR_UM * d->p.grain_dye_cloud
                           * sqrtf(fmaxf(od_particle, 0.0f)) * preview_scale;
        }

      cl_mem raw_buf[SF_GRAIN_MAX_SUBLAYERS] = { NULL };
      cl_mem acc_buf = dt_opencl_alloc_device_buffer(devid, npix * f);
      gboolean raw_ok = acc_buf != NULL;
      for(int sl = 0; sl < nsub; sl++)
      {
        raw_buf[sl] = dt_opencl_alloc_device_buffer(devid, npix * f);
        if(!raw_buf[sl]) raw_ok = FALSE;
      }
      if(!raw_ok)
        err = CL_MEM_OBJECT_ALLOCATION_FAILURE;
      else
      {
        const int n_out_ch = mono ? 1 : 3;
        for(int oc = 0; oc < n_out_ch && err == CL_SUCCESS; oc++)
        {
          const int channel_idx = mono ? 1 : oc;
          const int seed_ch = mono ? 0 : oc;
          const float unif_ch = g->grain_uniformity[channel_idx];
          for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++)
          {
            err = dt_opencl_enqueue_kernel_2d_args(
                devid, gd->kernel_grain_gen_raw_sl, w, h, CLARG(plane2), CLARG(raw_buf[sl]),
                CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y), CLARG(mono), CLARG(channel_idx),
                CLARG(seed_ch), CLARG(sl), CLARG(nle), CLARG(maxsub), CLARG(unif_ch),
                CLARG(npart_scale), CLARG(salt), CLARG(d->grain_cl_dmax), CLARG(d->grain_cl_npart),
                CLARG(d->grain_cl_dmin), CLARG(d->grain_cl_total), CLARG(d->grain_cl_curve));
            if(err == CL_SUCCESS && dye_sigma[channel_idx][sl] > 1e-6f)
              /* exact-only: the CPU twin is sf_blur_plane1(), which never takes
                 the recursive path (see SF_GAUSS_BLUR1_EXACT) */
              SF_GAUSS_BLUR1_EXACT(raw_buf[sl], dye_sigma[channel_idx][sl]);
          }
          for(int sl = 0; sl < nsub && err == CL_SUCCESS; sl++)
          {
            const int reset = sl == 0;
            err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_accumulate_1c, w, h,
                                                   CLARG(acc_buf), CLARG(raw_buf[sl]), CLARG(w),
                                                   CLARG(h), CLARG(reset));
          }
          if(err == CL_SUCCESS)
          {
            const float dmin_ch = g->grain_dmin[channel_idx];
            err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_finalize_channel, w, h,
                                                   CLARG(tmpa), CLARG(acc_buf), CLARG(plane2),
                                                   CLARG(w), CLARG(h), CLARG(mono),
                                                   CLARG(channel_idx), CLARG(oc), CLARG(dmin_ch),
                                                   CLARG(amount));
          }
        }
      }
      for(int sl = 0; sl < nsub; sl++)
        if(raw_buf[sl]) dt_opencl_release_mem_object(raw_buf[sl]);
      if(acc_buf) dt_opencl_release_mem_object(acc_buf);
    }
    SF_CL_STEP("grain gen");
    /* No DC-centring reduction, matching the CPU path: the Poisson sampler is
       unbiased, so there is nothing to centre. Centring would also need a full
       device->host readback, stalling the queue once per grain stage. */
    /* Add the still-UNBLURRED delta, so the blur below sees the grained
       absolute density, not an isolated grain layer -- same ordering as
       process(), see the long comment there for why the blur and the unsharp
       mask that follows it only make sense as a pair. */
    err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_add, w, h, CLARG(plane2),
                                           CLARG(tmpa), CLARG(w), CLARG(h));
    SF_CL_STEP("grain add");
    /* fixed pixel sigma, matching process()'s CPU-side fix -- see comment
       there for the empirical validation. */
    const float gsigma = d->p.grain_blur_base * fmaxf(d->p.grain_blur, SF_GRAIN_BLUR_MIN)
                          * preview_scale;
    /* CPU twin is sf_blur_plane3(), which skips below SF_GAUSS_MIN_SIGMA.
       This one matters most: the clump blur runs on the grain field, which is
       high-frequency by construction, so blurring here where the CPU does not
       changes essentially every grained pixel. */
    if(gsigma >= SF_GAUSS_MIN_SIGMA)
      SF_GAUSS_BLUR4(plane2, gsigma, "grain blur");
    if(d->p.grain_usm_sigma > 0.0f && d->p.grain_usm_amount > 0.0f)
    {
      err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane2, acc, 0, 0, npix * f * 4);
      if(err != CL_SUCCESS) goto cleanup;
      const float usig = d->p.grain_usm_sigma * preview_scale;
      /* sf_multiplicative_unsharp_mask3() blurs via sf_blur_plane3(), so this
         is exact-only and skipped below SF_GAUSS_MIN_SIGMA -- not _FAST. */
      if(usig >= SF_GAUSS_MIN_SIGMA)
        SF_GAUSS_BLUR4(plane2, usig, "grain USM blur");
      err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_grain_usm, w, h, CLARG(plane2),
                                             CLARG(acc), CLARG(w), CLARG(h),
                                             CLARG(d->p.grain_usm_amount),
                                             CLARG(g->grain_dmin[0]), CLARG(g->grain_dmin[1]),
                                             CLARG(g->grain_dmin[2]));
      SF_CL_STEP("grain USM");
    }
  }

  /* ---- 5) print ----------------------------------------------------------- */
  if(g->has_print)
  {
    err = dt_opencl_enqueue_kernel_2d_args(
        devid, gd->kernel_print_expose, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h),
        CLARG(el_cl), CLARG(ex_cl), CLARG(ey_cl), CLARG(ez_cl), CLARG(en_cl), CLARG(em_cl),
        CLARG(steps), CLARG(g->enl_lo[0]), CLARG(g->enl_lo[1]), CLARG(g->enl_lo[2]),
        CLARG(g->enl_inv_range[0]), CLARG(g->enl_inv_range[1]), CLARG(g->enl_inv_range[2]),
        CLARG(g->log10_print_exposure));
    SF_CL_STEP("print_expose");
    /* ---- print diffusion (optional, on linear print exposure) ---- */
    if(d->p.print_diffusion_on)
    {
      sf_diffusion_plan_t pplan;
      if(sf_diffusion_build_plan((int)d->p.print_diffusion_filter_family,
                                 d->p.print_diffusion_strength,
                                 d->p.print_diffusion_warmth, &pplan)
         && pplan.p_s > 0.0f)
      {
        /* linear print exposure, see process() */
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_pow10, w, h, CLARG(plane),
                                               CLARG(w), CLARG(h));
        SF_CL_STEP("print_diffusion pow10");
        /* see the pre-film diffusion above for why this is in double */
        const double pdsc = fmax((double)d->p.print_diffusion_scale, 1e-6);
        for(int j = 0; j < pplan.n; j++)
        {
          const float sigma
              = (float)((double)pplan.sigma_um[j] * pdsc / fmax((double)pixel_um, 1e-3));
          SF_BLUR4_WIDE_OP_L(plane, tmpa, sigma);
          if(err != CL_SUCCESS) break;
          const int reset = (j == 0);
          const float wr = pplan.wr[j], wg = pplan.wg[j], wb = pplan.wb[j];
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_accum, w, h,
                                                  CLARG(tmpa), CLARG(acc), CLARG(w), CLARG(h),
                                                  CLARG(wr), CLARG(wg), CLARG(wb), CLARG(reset));
          if(err != CL_SUCCESS) break;
        }
        if(err == CL_SUCCESS)
        {
          const float ps = pplan.p_s;
          err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_diffusion_mix, w, h,
                                                  CLARG(plane), CLARG(acc), CLARG(w), CLARG(h),
                                                  CLARG(ps));
        }
        SF_CL_STEP("print_diffusion");
        err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_log10, w, h, CLARG(plane),
                                               CLARG(w), CLARG(h));
        SF_CL_STEP("print_diffusion log10");
      }
    }
    err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_print_develop, w, h, CLARG(plane),
                                           CLARG(plane2), CLARG(w), CLARG(h), CLARG(pc_cl),
                                           CLARG(g->le0), CLARG(g->inv_le_step));
    SF_CL_STEP("print_develop");
  }

  /* ---- 6) scan over the full padded ROI into `plane` (free since print) ---- */
  /* viewing glare field into tmpa, added in XYZ inside the scan (mirrors
     process()); tmpa is free since development */
  const int glare_on = !d->p.scan_film && d->p.glare_percent > 0.0f;
  if(glare_on)
  {
    const float gmean = d->p.glare_percent * 0.01f;
    const float sigma2 = logf(1.0f + SF_GLARE_ROUGHNESS * SF_GLARE_ROUGHNESS);
    const float gs = sqrtf(sigma2), gbias = -0.5f * sigma2;
    const int roi_x = roi_in->x, roi_y = roi_in->y;
    const uint32_t salt = _grain_salt(piece);
    err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_glare_gen, w, h, CLARG(tmpa),
                                           CLARG(w), CLARG(h), CLARG(roi_x), CLARG(roi_y),
                                           CLARG(gmean), CLARG(gs), CLARG(gbias), CLARG(salt));
    SF_CL_STEP("glare gen");
    SF_GAUSS_BLUR4(tmpa, SF_GLARE_BLUR_PX * preview_scale, "glare blur");
  }
  err = dt_opencl_enqueue_kernel_2d_args(
      devid, gd->kernel_scan, w, h, CLARG(plane2), CLARG(plane), CLARG(w), CLARG(h),
      CLARG(sl_cl), CLARG(sx_cl), CLARG(sy_cl),
      CLARG(sz_cl), CLARG(sn_cl), CLARG(sm_cl), CLARG(steps), CLARG(g->scan_lo[0]),
      CLARG(g->scan_lo[1]), CLARG(g->scan_lo[2]), CLARG(g->scan_inv_range[0]),
      CLARG(g->scan_inv_range[1]), CLARG(g->scan_inv_range[2]), CLARG(mats_cl), CLARG(cm_cl),
      CLARG(g->cmax_nl), CLARG(g->cmax_nh),
      CLARG(g->out_compress), CLARG(g->out_luminance_boost), CLARG(g->out_scale),
      CLARG(g->scan_bw_on), CLARG(g->scan_bw_m),
      CLARG(g->scan_bw_q), CLARG(tmpa), CLARG(glare_on), CLARG(g->glare_xyz[0]),
      CLARG(g->glare_xyz[1]), CLARG(g->glare_xyz[2]));
  SF_CL_STEP("scan");

  /* ---- 6b) scanner optics (mirrors process()) ------------------------------ */
  if(d->p.scan_blur > 0.0f)
  {
    /* CPU twin is sf_blur_plane3(); mirror its SF_GAUSS_MIN_SIGMA cut */
    if(d->p.scan_blur * preview_scale >= SF_GAUSS_MIN_SIGMA)
      SF_GAUSS_BLUR4(plane, d->p.scan_blur * preview_scale, "scanner blur");
  }
  if(d->p.scan_usm_sigma > 0.0f && d->p.scan_usm_amount > 0.0f)
  {
    err = dt_opencl_enqueue_copy_buffer_to_buffer(devid, plane, acc, 0, 0, npix * f * 4);
    if(err != CL_SUCCESS) goto cleanup;
    /* sf_unsharp_mask3() blurs via sf_blur_plane3(): exact-only, and skipped
       below SF_GAUSS_MIN_SIGMA -- not _FAST. */
    if(d->p.scan_usm_sigma * preview_scale >= SF_GAUSS_MIN_SIGMA)
      SF_GAUSS_BLUR4(plane, d->p.scan_usm_sigma * preview_scale, "scanner USM blur");
    err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_scan_usm, w, h, CLARG(plane),
                                           CLARG(acc), CLARG(w), CLARG(h),
                                           CLARG(d->p.scan_usm_amount));
    SF_CL_STEP("scanner USM");
  }

  /* ---- 7) crop the roi_out window into dev_out ----------------------------- */
  err = dt_opencl_enqueue_kernel_2d_args(devid, gd->kernel_crop_out, ow, oh, CLARG(plane),
                                         CLARG(dev_in), CLARG(dev_out), CLARG(w), CLARG(ow),
                                         CLARG(oh), CLARG(ox), CLARG(oy));
  SF_CL_STEP("crop out");

cleanup:
  dt_opencl_release_mem_object(mats_cl);
  dt_opencl_release_mem_object(tc_cl);
  dt_opencl_release_mem_object(cn_cl);
  dt_opencl_release_mem_object(cb_cl);
  dt_opencl_release_mem_object(el_cl);
  dt_opencl_release_mem_object(ex_cl);
  dt_opencl_release_mem_object(ey_cl);
  dt_opencl_release_mem_object(ez_cl);
  dt_opencl_release_mem_object(en_cl);
  dt_opencl_release_mem_object(em_cl);
  dt_opencl_release_mem_object(pc_cl);
  dt_opencl_release_mem_object(sl_cl);
  dt_opencl_release_mem_object(sx_cl);
  dt_opencl_release_mem_object(sy_cl);
  dt_opencl_release_mem_object(sz_cl);
  dt_opencl_release_mem_object(sn_cl);
  dt_opencl_release_mem_object(sm_cl);
  dt_opencl_release_mem_object(cm_cl);
  dt_opencl_release_mem_object(plane);
  dt_opencl_release_mem_object(plane2);
  dt_opencl_release_mem_object(tmpa);
  dt_opencl_release_mem_object(acc);
  dt_opencl_release_mem_object(plane1);
  dt_opencl_release_mem_object(gtmp4);
  dt_opencl_release_mem_object(gtmp1);
  dt_opencl_release_mem_object(gauss_w);
  return err;
}
#endif /* HAVE_OPENCL */

/* ---------------------------------------------------------------------- */
/* GUI                                                                    */
/* ---------------------------------------------------------------------- */

static void _rescan(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  g_list_free_full(g->entries, g_free);
  g->entries = _scan_profiles(NULL);
  /* the tables come from the same pack as the profiles, so they are rescanned
     together: a reload that changes one changes the other */
  g_list_free_full(g->tables, g_free);
  g->tables = _scan_tables();
}

/* Entry at a list position, or NULL. The comboboxes carry the position as their
   data, so this is how a user selection resolves back to a profile. */
static const sf_prof_entry_t *_entry_at(const dt_iop_spektrafilm_gui_data_t *g,
                                        const int pos)
{
  return (pos >= 0) ? g_list_nth_data(g->entries, pos) : NULL;
}

static void _update_print_sensitivity(dt_iop_module_t *self);
static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
                                            const dt_iop_spektrafilm_params_t *p);
static float _development_default(const sf_prof_entry_t *e);
static void _rebaseline_print_development(dt_iop_module_t *self);
static void _sync_coupler_diffusion(dt_iop_spektrafilm_gui_data_t *g,
                                    const sf_prof_entry_t *e);

/* forward: needs _entry_by_hash(), which is defined below with the rest of the
   stock lookups */
static void _update_paper_auto_entry(dt_iop_module_t *self);
static const sf_prof_entry_t *_current_film_entry(const dt_iop_spektrafilm_gui_data_t *g,
                                                  const dt_iop_spektrafilm_params_t *p);
static const sf_prof_entry_t *_effective_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
                                                     const dt_iop_spektrafilm_params_t *p);

/* Stamp the spectral table this edit is being made against, at the point where
   a user change has written its param and the history item has not been created
   yet, so the value lands in the same edit.

   Every path that records a user change has to call this, not just the
   params-linked widgets that reach gui_changed(): the film and paper
   comboboxes carry their own callbacks and commit history directly, and they
   are the changes most likely to be an edit's first and only one. An edit that
   never stamps keeps lut_hash 0, which reads as "no table recorded" -- the
   mismatch warning in _ensure_sim() and the header row both skip on it, so the
   edit silently renders against whatever pack is installed when it is reopened.

   Only stamp when there is nothing to lose: no table recorded yet, or the
   loaded pack is already the one recorded. Overwriting a DIFFERENT recorded
   hash would throw away the only record of which pack renders this edit as it
   was made, and it would happen on any incidental slider touch while the
   mismatch warning was on screen saying the data was wrong. That record is what
   the download button uses to fetch the right pack, so losing it turns a
   fixable mismatch into a permanent one. */
static void _stamp_lut_hash(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  /* Nothing left to record once a table is recorded, which is every edit
     after its first stamp. Both writes below are then no-ops: the table is
     only ever set when it is 0 or already equal, and the pack only onto an
     edit that names neither. Testing pack_hash here as well kept this from
     ever returning early on a pack that declares no identity -- every
     pack_format 2 pack -- so each slider step resolved the pack again,
     several parses of pack.json on the GUI thread. */
  if(p->lut_hash) return;
  /* _pack is whichever pack a pipe loaded last, which can belong to another
     image entirely. Only stamp it when it is the pack this edit resolves to */
  char want_dir[SF_PATH_LEN];
  if(_edit_pack_dir(p, want_dir, sizeof want_dir, NULL) != SF_EDIT_PACK_OK) return;
  dt_pthread_mutex_lock(&_pack_lock);
  if(_pack && strcmp(_pack_path, want_dir) == 0)
  {
    /* only an edit that names neither a table nor a pack is still unbound. One
       that names a table but no pack was rendered on a pack without an
       identity, and one reaching an identified pack now is on a substitute
       whatever its table says: stamping there would make the substitution
       permanent, surviving the moment its own pack comes back */
    const gboolean unbound = !p->lut_hash && !p->pack_hash;
    const uint32_t cur = sf_pack_lut_hash(_pack);
    if(!p->lut_hash || p->lut_hash == cur) p->lut_hash = cur;
    /* the pack is recorded only onto an unbound edit, and never overwritten:
       that would erase the very mismatch the field exists to report */
    const uint32_t curp = sf_pack_hash(_pack);
    if(curp && unbound) p->pack_hash = curp;
  }
  dt_pthread_mutex_unlock(&_pack_lock);
}

/* both halves of the hanatos sensitivity adaptation belong to the irradiance
   formula; sf_sim_build() does not run them for a reflectance table. Shown
   inert there rather than left live, which would offer two switches that
   change nothing */
static void _update_adaptation_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
                                           const sf_table_entry_t *sel)
{
  const gboolean reflectance = sel && sel->reflectance;
  gtk_widget_set_sensitive(g->adaptation_bandwidth, !reflectance);
  gtk_widget_set_sensitive(g->adaptation_surface, !reflectance);
}

static void _upsampling_changed(GtkWidget *w,
                                dt_iop_module_t *self)
{
  if(darktable.gui->reset) return;
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  const sf_table_entry_t *e
      = _table_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->upsampling)));
  if(!e) return;
  /* the hash and not the list position: a pack revision can reorder its tables
     or drop one, and the edit has to keep naming the table it was developed
     against so the mismatch is reported rather than absorbed */
  p->upsampling_hash = e->hash;
  /* the chosen table may live in another pack than the one this edit currently
     resolves to: picking a method a later pack added is the whole point of
     listing them all. Release the recorded pack and table so resolution
     follows the choice; both are stamped again on the next render, naming
     whichever pack actually supplied it */
  p->pack_hash = 0u;
  p->lut_hash = 0u;
  /* here as well as in gui_update(): picking a table is exactly when the pair
     becomes inert or live again, and gui_update() does not re-run on a
     combobox change */
  _update_adaptation_sensitivity(g, e);
  dt_dev_add_history_item(darktable.develop, self, TRUE);
}

static void _film_changed(GtkWidget *w,
                          dt_iop_module_t *self)
{
  if(darktable.gui->reset) return;
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  const sf_prof_entry_t *e
      = _entry_at(g, GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->film)));
  if(!e) return;
  p->film_hash = e->hash;
  /* A development time from the previous stock means nothing here -- the times
     differ per stock, and a stale value can sit above the new stock's longest
     (Double-X at 12 min, then 2302's family topping out at 9). Land on the new
     stock's own default, so the slider always shows a time it actually has. */
  p->development_min = _development_default(e);
  DT_ENTER_GUI_UPDATE();
  dt_bauhaus_slider_set(g->development_min, p->development_min);
  DT_LEAVE_GUI_UPDATE();
  /* scan-film follows the film's natural mode on a film switch: slides and
     reversal stocks are viewed directly (scan), negatives go through the
     print stage. The user can still choose freely afterwards -- this only
     re-baselines when the film itself changes, like the paper auto-follow.
     The paper combobox is what expresses this state, so it moves with the
     value: -2 for "none (scan the film)", -1 for "auto". */
  if(p->scan_film != e->positive)
  {
    p->scan_film = e->positive;
    /* the black/white point goes with it, as in upstream's scan-for-print
       mode: a slide scans washed out without it, and a print is not fitted
       unless asked */
    p->scan_black_correction = p->scan_white_correction = e->positive;
    /* leaving scan mode returns to whatever paper_hash names: "auto" when it
       is 0, otherwise the pinned paper, which is still what the pipeline
       prints on */
    int ppos = SF_COMBO_PAPER_AUTO;
    if(p->scan_film)
      ppos = SF_COMBO_SCAN_FILM;
    else if(p->paper_hash)
    {
      const sf_prof_entry_t *pe = _effective_paper_entry(g, p);
      if(pe) ppos = g_list_index(g->entries, pe);
    }
    DT_ENTER_GUI_UPDATE();
    dt_bauhaus_combobox_set_from_value(g->paper, ppos);
    dt_bauhaus_toggle_set(g->scan_black_correction, p->scan_black_correction);
    dt_bauhaus_toggle_set(g->scan_white_correction, p->scan_white_correction);
    DT_LEAVE_GUI_UPDATE();
  }
  /* Unconditional: the paper combobox follows the film's own positive flag,
     which can change while scan_film does not -- selecting "none" on a
     negative and then switching to a positive stock leaves scan_film already
     set, and the combobox would stay live on a film that has no print
     stage. */
  _update_print_sensitivity(self);
  /* On "auto" (hash 0) the paper follows the film's target print and the
     combobox keeps reading "auto" -- the selection is the link, not the
     destination -- with the entry's own text naming the stock it resolves to.
     Relabelled on every film change, so the entry is already correct whenever
     the paper is set back to auto later. The pipeline resolves it identically
     either way (_resolve_stock). */
  _update_paper_auto_entry(self);
  /* A film switch can move the automatic paper (Double-X resolves to print
     film 2302, a color negative to a color paper), and the print time does
     not transfer between them any more than the film time does. Only on auto:
     an explicitly chosen paper has not changed, so neither should its time */
  if(!p->paper_hash) _rebaseline_print_development(self);
  /* moves the coupler spread sliders' reset targets onto this stock, without
     touching the values the user set */
  _sync_coupler_diffusion(g, e);
  /* last, once scan_film and the auto-followed paper have settled: both
     development sliders are gated on their own stock, and the print one also on
     there being a print stage at all */
  _update_development_sensitivity(g, p);
  _stamp_lut_hash(self);
  dt_dev_add_history_item(darktable.develop, self, TRUE);
}

static void _paper_changed(GtkWidget *w,
                           dt_iop_module_t *self)
{
  if(darktable.gui->reset) return;
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  const int ppos = GPOINTER_TO_INT(dt_bauhaus_combobox_get_data(g->paper));
  const gboolean scan = (ppos == SF_COMBO_SCAN_FILM);
  if(p->scan_film != scan)
  {
    p->scan_film = scan;
    _update_print_sensitivity(self);
  }
  if(ppos < 0)
  {
    /* auto, or no paper at all: drop the explicit choice so the film resolves
       it again if the print stage comes back */
    p->paper_hash = 0;
    /* then take the time from whatever that resolves to. Zeroing here instead
       would render the same (0 means the stock's own default) but leave the
       slider reading 0 on a paper that has a whole family of times */
    _rebaseline_print_development(self);
    _update_development_sensitivity(g, p);
    _stamp_lut_hash(self);
    dt_dev_add_history_item(darktable.develop, self, TRUE);
    return;
  }
  const sf_prof_entry_t *pe = _entry_at(g, ppos);
  if(!pe) return;
  p->paper_hash = pe->hash;
  /* same as _film_changed: a time from the previous paper does not transfer */
  p->print_development_min = _development_default(pe);
  DT_ENTER_GUI_UPDATE();
  dt_bauhaus_slider_set(g->print_development_min, p->print_development_min);
  DT_LEAVE_GUI_UPDATE();
  _update_development_sensitivity(g, p);
  _stamp_lut_hash(self);
  dt_dev_add_history_item(darktable.develop, self, TRUE);
}

/* Entry for a stock hash, or NULL. `printing` disambiguates, since the same
   stock name can exist as both a film and a paper. */
static const sf_prof_entry_t *_entry_by_hash(const dt_iop_spektrafilm_gui_data_t *g,
                                             const uint32_t hash,
                                             const gboolean printing)
{
  for(const GList *l = g->entries; l; l = l->next)
  {
    const sf_prof_entry_t *e = l->data;
    if(e->hash == hash && e->printing == printing) return e;
  }
  return NULL;
}

/* The film the pipeline is rendering with: the stock this edit names, or -- for
   a hash of 0, or a stock that has since left the pack -- the same fallback
   gui_update() selects the combobox on, so the label below never names a film
   the combobox does not show. */
static const sf_prof_entry_t *_current_film_entry(const dt_iop_spektrafilm_gui_data_t *g,
                                                  const dt_iop_spektrafilm_params_t *p)
{
  const sf_prof_entry_t *hit = _entry_by_hash(g, p->film_hash, FALSE);
  if(p->film_hash && hit) return hit;
  const sf_prof_entry_t *fallback = NULL;
  for(const GList *l = g->entries; l; l = l->next)
  {
    const sf_prof_entry_t *e = l->data;
    if(e->printing) continue;
    if(!fallback || !strcmp(e->stock, "kodak_gold_200")) fallback = e;
  }
  return fallback;
}

/* The paper the pipeline actually prints on while "auto" is selected: the film's
   own target print when the pack ships it as a paper, otherwise the first print
   stock in the list. The second half mirrors _resolve_stock()'s own last
   resort, so the label names the paper in the two cases target_print alone does
   not cover: a film that names no target print, and Double-X, whose target 2302
   the pack exports as a film and not as a paper. */
static const sf_prof_entry_t *_auto_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
                                                const sf_prof_entry_t *film)
{
  const sf_prof_entry_t *first = NULL;
  const sf_prof_entry_t *same_channel = NULL;
  const char *target = film && film->target_print[0] ? film->target_print : NULL;
  for(const GList *l = g->entries; l; l = l->next)
  {
    const sf_prof_entry_t *pe = l->data;
    if(!pe->printing) continue;
    if(!first) first = pe;
    if(film && !same_channel && pe->bw == film->bw) same_channel = pe;
    if(target && !strcmp(pe->stock, target)) return pe;
  }
  /* Three tiers, in order: the film's own named target print, then any print
     stock with the same channel model, then the first printing entry there is.
     The channel-model tier matters because target_print is optional in the pack
     and the entry list is sorted by display name, so the first printing entry
     bears no relation to the film -- for a black-and-white negative it is a
     colour paper. Stocks that name no target and print nonetheless are rare
     (kodak_doublex is the one in the current pack; the other untargeted stocks
     are positives, which force scan_film and skip the print stage), so the last
     tier is only reached when nothing in the pack matches at all. */
  return same_channel ? same_channel : first;
}

/* Name the resolved paper in the "auto" entry itself, not only in the
   tooltip. The combobox deliberately keeps reading "auto" while it follows a
   film (the selection is the link, not the destination), so without this the
   paper actually being printed on is visible only on hover. Renaming the entry
   shows both at once and keeps the link intact -- the selection does not move,
   only its text changes, so paper_hash stays 0.

   With scan_film there is no print stage, but the entry keeps its name: on a
   negative the combobox stays live while scanning and "auto" is one of the ways
   back to printing, so it has to remain readable in the list. Only the tooltip
   says that no paper is in use. */
static void _update_paper_auto_entry(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  const dt_iop_spektrafilm_params_t *p = (const dt_iop_spektrafilm_params_t *)self->params;
  if(!g || !g->paper) return;
  char label[SF_NAME_LEN + 32];
  char tip[SF_NAME_LEN + 256];
  const sf_prof_entry_t *re = _auto_paper_entry(g, _current_film_entry(g, p));
  /* the label names what selecting the entry would print on, scanning or not */
  if(re)
    snprintf(label, sizeof label, _("auto (%s)"), re->name);
  else
    g_strlcpy(label, _("auto"), sizeof label);
  if(p->scan_film)
  {
    g_strlcpy(tip, _("print paper. not used: the film is being scanned directly."),
              sizeof tip);
  }
  else if(re)
  {
    snprintf(tip, sizeof tip,
             _("print paper. \"auto\" follows the film stock's own target print,\n"
               "currently %s. picking a paper pins it until you select auto again."),
             re->name);
  }
  else
  {
    /* no print stock in the pack at all -- the list below reads "(none)" */
    g_strlcpy(tip, _("print paper. \"auto\" follows the film stock's own target print."),
              sizeof tip);
  }
  /* The auto entry is added before any section header, so its list index is
     fixed whatever paper groups the pack turns out to contain -- but it is not
     necessarily the first entry, so use the index recorded when the list was
     built rather than assuming one. */
  dt_bauhaus_combobox_set_entry_label(g->paper, g->paper_auto_pos, label);
  /* The widget renders the active entry's label straight out of that array at
     draw time, so a relabel needs nothing but a redraw -- and does need one. */
  gtk_widget_queue_draw(g->paper);
  gtk_widget_set_tooltip_text(g->paper, tip);
}

/* Default development time for a stock, in minutes: the representative middle
   member of its family, which is what select_development_time(None) picks. 0 when
   the stock is characterised at a single development. */
static float _development_default(const sf_prof_entry_t *e)
{
  return (e && e->n_dev > 1) ? (float)e->dev_times[(e->n_dev - 1) / 2] : 0.0f;
}

/* The inhibitor spread this stock ships, so the sliders can show real numbers
   instead of the -1 sentinel the params carry for "ask the pack". Falls back to
   the reference's own DirCouplersParams defaults when the pack has no entry for
   the stock, which is also what sf_sim_build() lands on. */
static void _coupler_diffusion_default(const sf_prof_entry_t *e,
                                       float *size_um,
                                       float *tail_um,
                                       float *tail_w)
{
  double d = SF_COUPLER_BLUR_UM, t = 0.0, w = 0.0;
  dt_pthread_mutex_lock(&_pack_lock);
  if(_pack && e) sf_pack_film_coupler_diffusion(_pack, e->stock, &d, &t, &w);
  dt_pthread_mutex_unlock(&_pack_lock);
  if(w <= 0.0 || t <= 0.0) { t = 0.0; w = 0.0; }
  *size_um = (float)d;
  *tail_um = (float)t;
  *tail_w = (float)w;
}

/* Point the three diffusion sliders' reset targets at this stock's own numbers,
   leaving the values alone. The pack fits the spread per stock, so a reset
   should land on what the loaded film ships -- but the values are the user's,
   and a film change is not a reason to discard them. The development time above
   is overwritten instead because a time from another stock may not exist on
   this one; a spread in micrometres is meaningful on any emulsion. */
static void _sync_coupler_diffusion(dt_iop_spektrafilm_gui_data_t *g,
                                    const sf_prof_entry_t *e)
{
  float d, t, w;
  _coupler_diffusion_default(e, &d, &t, &w);
  DT_ENTER_GUI_UPDATE();
  dt_bauhaus_slider_set_default(g->couplers_diffusion_um, d);
  dt_bauhaus_slider_set_default(g->couplers_tail_um, t);
  dt_bauhaus_slider_set_default(g->couplers_tail_weight, w);
  DT_LEAVE_GUI_UPDATE();
}

/* Point one development slider at one stock: sensitive only where that stock is
   characterised at more than one development time, spanning exactly the times it
   offers, and naming them -- they differ per stock and the value snaps to them,
   so a bare 0-15 range would be guesswork. */
static void _development_widget_update(GtkWidget *w,
                                       const sf_prof_entry_t *e)
{
  if(!w) return;
  const gboolean have = (e && e->n_dev > 1);
  /* Never hidden. The print slider is the only widget in the print "chemistry"
     section, so hiding it leaves a collapsible with an empty body -- a heading
     and a disclosure arrow that open onto nothing, which reads as a broken
     panel rather than as an option that does not apply here. A stock
     characterised at one development still has a development time, and the
     slider shows it; it just cannot be moved, which greying it out says
     plainly. */
  gtk_widget_set_visible(w, TRUE);
  gtk_widget_set_sensitive(w, have);

  float hi = 15.0f;
  if(have)
  {
    hi = (float)e->dev_times[0];
    for(int i = 1; i < e->n_dev; i++) hi = fmaxf(hi, (float)e->dev_times[i]);
  }
  /* 0 stays reachable at the bottom -- it means "this stock's own default" -- and
     the hard 15 min ceiling covers the widest family in the release (Double-X,
     12 min) with room to spare. */
  dt_bauhaus_slider_set_soft_range(w, 0.0f, hi);
  /* Reset gestures (double-click, scroll-reset) go to the widget's own default,
     which introspection set to the compiled 0. That renders correctly -- 0 means
     "this stock's default" -- but leaves the slider reading 0 instead of the time
     it resolved to, on the one discontinuity in the range. Point it at the real
     number for the stock in hand, so a reset shows 6.5 min on Double-X and 5 min on 2302, not 0. */
  dt_bauhaus_slider_set_default(w, _development_default(e));

  if(have)
  {
    char times[128] = { 0 };
    for(int i = 0; i < e->n_dev; i++)
    {
      char one[24];
      snprintf(one, sizeof one, "%s%.3g", i ? ", " : "", e->dev_times[i]);
      g_strlcat(times, one, sizeof times);
    }
    char tip[320];
    snprintf(tip, sizeof tip,
             _("development time, in minutes. snaps to the nearest time %s is\n"
               "characterized at: %s min. 0 uses the stock's own default (%.3g min)."),
             e->name, times, (double)_development_default(e));
    gtk_widget_set_tooltip_text(w, tip);
  }
  else
    gtk_widget_set_tooltip_text(w,
                                _("development time. this stock is characterized at a "
                                  "single\n"
                                  "development, so there is nothing to choose.\n"
                                  "\n"
                                  "the stocks that carry a family of times are "
                                  "Double-X and print film 2302:\n"
                                  "Double-X at 4/5/6.5/9/12 min, print film 2302 at "
                                  "2/3.5/5/7/9 min."));
}

/* Film and print are separate chemistries developed for separate times, so they
   get a slider each, pointed at their own stock. Lives in its own function
   because every film / paper / scan_film change routes through here; driving
   the sliders from gui_update() alone would leave them stale from the moment a
   stock is switched until the module is next rebuilt. */
/* the paper actually being printed on, or NULL when there is no print stage.
   "auto" prints on a real paper while leaving paper_hash at 0: the link is
   the selection, the destination is resolved from the film's target print. A
   hash lookup alone finds no paper on that selection, which would leave the
   print slider dead at 0 min even when the paper it resolves to carries a whole
   development family. Resolved the same way the combobox label and the pipeline
   resolve it, so all three name one paper */
static const sf_prof_entry_t *_effective_paper_entry(const dt_iop_spektrafilm_gui_data_t *g,
                                                     const dt_iop_spektrafilm_params_t *p)
{
  if(p->scan_film) return NULL;
  const sf_prof_entry_t *pinned = p->paper_hash ? _entry_by_hash(g, p->paper_hash, TRUE) : NULL;
  /* a pinned paper missing from the pack falls back the way _resolve_stock()
     does, to the film's automatic paper */
  return pinned ? pinned : _auto_paper_entry(g, _current_film_entry(g, p));
}

/* put the print development slider on the paper in force, as _film_changed()
   does for the film: a time from the previous paper means nothing on this one,
   and 0 ("this stock's own default") renders correctly but reads as though
   nothing is set, on the one discontinuity in the range */
static void _rebaseline_print_development(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  const sf_prof_entry_t *paper = _effective_paper_entry(g, p);
  p->print_development_min = paper ? _development_default(paper) : 0.0f;
  DT_ENTER_GUI_UPDATE();
  dt_bauhaus_slider_set(g->print_development_min, p->print_development_min);
  DT_LEAVE_GUI_UPDATE();
}

static void _update_development_sensitivity(const dt_iop_spektrafilm_gui_data_t *g,
                                            const dt_iop_spektrafilm_params_t *p)
{
  _development_widget_update(g->development_min, _entry_by_hash(g, p->film_hash, FALSE));

  _development_widget_update(g->print_development_min, _effective_paper_entry(g, p));
}

static void _update_print_sensitivity(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  const gboolean printing = !p->scan_film;
  /* The paper combobox is gated on the film, not on `printing`. A positive or
     reversal stock has no print stage at all, so a paper is not a choice there
     and picking one would put the image through a print it never has. For a
     negative the combobox stays live even while scanning, because it is the
     control that turns scanning off again -- gating it on `printing` would
     leave no way back. */
  {
    const sf_prof_entry_t *fe = _current_film_entry(g, p);
    gtk_widget_set_sensitive(g->paper, !(fe && fe->positive));
  }
  gtk_widget_set_sensitive(g->print_exposure_ev, printing);
  gtk_widget_set_sensitive(g->print_auto_exposure, printing);
  gtk_widget_set_sensitive(g->print_contrast, printing);
  gtk_widget_set_sensitive(g->print_gamma_r, printing);
  gtk_widget_set_sensitive(g->print_gamma_g, printing);
  gtk_widget_set_sensitive(g->print_gamma_b, printing);
  /* the endpoint fit runs where the endpoints come from something other than
     the medium being rendered: a scan of positive film, whose slide has base
     density and never reaches D-max, and a print on negative-type paper, whose
     range is set by the film behind it. A scan of a negative is not placed this
     way, and positive paper carries its own black and white */
  const sf_prof_entry_t *film_now = _current_film_entry(g, p);
  const sf_prof_entry_t *paper_now = _effective_paper_entry(g, p);
  const gboolean bw_point = printing ? (paper_now && !paper_now->positive)
                                     : (film_now && film_now->positive);
  gtk_widget_set_sensitive(g->scan_black_correction, bw_point);
  gtk_widget_set_sensitive(g->scan_white_correction, bw_point);
  /* a level does nothing while its correction is off */
  gtk_widget_set_sensitive(g->scan_black_level, bw_point && p->scan_black_correction);
  gtk_widget_set_sensitive(g->scan_white_level, bw_point && p->scan_white_correction);
  gtk_widget_set_sensitive(g->filter_m, printing);
  gtk_widget_set_sensitive(g->filter_y, printing);
  gtk_widget_set_sensitive(g->print_diffusion_on, printing);
  gtk_widget_set_sensitive(g->print_diffusion_filter_family, printing && p->print_diffusion_on);
  gtk_widget_set_sensitive(g->print_diffusion_strength, printing && p->print_diffusion_on);
  gtk_widget_set_sensitive(g->print_diffusion_scale, printing && p->print_diffusion_on);
  gtk_widget_set_sensitive(g->print_diffusion_warmth, printing && p->print_diffusion_on);
  gtk_widget_set_sensitive(g->preflash_exposure, printing);
  gtk_widget_set_sensitive(g->preflash_m_shift, printing);
  gtk_widget_set_sensitive(g->preflash_y_shift, printing);
  /* viewing glare simulates light reflecting off the print surface, so it has
     no effect once scan_film skips the print stage entirely. process() and
     process_cl() already skip it (see the `!d->p.scan_film` guards), this
     just keeps the slider from looking live when it does nothing. */
  gtk_widget_set_sensitive(g->glare_percent, printing);
  /* toggle_from_params checkboxes keep showing their tick even when made
     insensitive -- GTK just dims the whole widget, so a checked-but-grayed
     box can read as "this is still on" when it has no effect at all (no
     print stage on positive/reversal film). Blank the tick while
     insensitive and restore the real value once re-enabled. Wrapped in
     DT_ENTER/LEAVE_GUI_UPDATE -- the same guard dt_iop_gui_update's own
     programmatic widget syncs rely on -- so this is purely visual and
     never writes back into the param. */
  DT_ENTER_GUI_UPDATE();
  dt_bauhaus_toggle_set(g->print_auto_exposure,
                        printing && p->print_auto_exposure);
  dt_bauhaus_toggle_set(g->print_diffusion_on,
                        printing && p->print_diffusion_on);
  DT_LEAVE_GUI_UPDATE();

  /* The auto entry names the paper in use, and with no print stage there is
     none: relabel from here, the one place every scan_film change passes. */
  _update_paper_auto_entry(self);

  /* Also from here: gui_changed() routes the scan_film toggle to this function
     and not to _toggle_sensitivity(), so this is the call that greys the print
     development slider out for a scan-the-film workflow, which has no print
     stage at all. */
  _update_development_sensitivity(g, p);
}

/* Grays out each effect's sub-controls when its master "enable" toggle is
   off. A clickable slider under an unchecked toggle reads as if it still does
   something. Covers halation, grain and both diffusion filters;
   _update_print_sensitivity above does the same for the print controls.

   Every gate here must match what process()/process_cl() actually skip: a
   control the pipeline still reads has to stay live, whichever tab it sits
   on. */
static void _toggle_sensitivity(dt_iop_spektrafilm_gui_data_t *g,
                                dt_iop_spektrafilm_params_t *p)
{
  const gboolean hal = p->halation_on;
  gtk_widget_set_sensitive(g->scatter_amount, hal);
  gtk_widget_set_sensitive(g->scatter_scale, hal);
  gtk_widget_set_sensitive(g->halation_amount, hal);
  gtk_widget_set_sensitive(g->halation_scale, hal);
  /* The highlight boost sits on the halation tab but is not halation's, and is
     not the two spatial stages' either: it runs ahead of both (see process()'s
     step 2) and process()/process_cl() reach it on boost_ev alone, with no
     toggle consulted on the way. boost_ev is therefore the only gate that
     matches the pipeline, and it gates only the two sliders that shape what it
     does -- boost_ev itself stays live, or there would be no way to switch the
     stage back on. */
  const gboolean bst = p->boost_ev > 0.0f;
  gtk_widget_set_sensitive(g->boost_range, bst);
  gtk_widget_set_sensitive(g->protect_ev, bst);

  const gboolean grn = p->grain_on;
  gtk_widget_set_sensitive(g->grain_amount, grn);
  gtk_widget_set_sensitive(g->grain_granularity, grn);
  gtk_widget_set_sensitive(g->grain_uniformity, grn);
  gtk_widget_set_sensitive(g->grain_sublayer_scale, grn);
  gtk_widget_set_sensitive(g->grain_density_min, grn);
  gtk_widget_set_sensitive(g->grain_blur, grn);
  gtk_widget_set_sensitive(g->grain_dye_cloud, grn);
  gtk_widget_set_sensitive(g->grain_usm_sigma, grn);
  gtk_widget_set_sensitive(g->grain_usm_amount, grn);

  const gboolean dif = p->diffusion_on;
  gtk_widget_set_sensitive(g->diffusion_filter_family, dif);
  gtk_widget_set_sensitive(g->diffusion_strength, dif);
  gtk_widget_set_sensitive(g->diffusion_scale, dif);
  gtk_widget_set_sensitive(g->diffusion_warmth, dif);

  /* scan_film as well as the toggle, because _update_print_sensitivity() gates
     these same four on `printing && print_diffusion_on` and this function is
     reached without it: gui_changed() pairs the two only for the
     print_diffusion_on widget, so a halation, grain, diffusion or boost_ev
     change ran this alone and re-enabled all four with the print gate dropped.
     print_diffusion_on stays TRUE while scanning -- _update_print_sensitivity()
     blanks its tick but deliberately leaves the param -- so the sliders came
     back live on a workflow that has no print stage at all. Same hazard the
     development sliders are protected from at the end of this function. */
  const gboolean pdif = p->print_diffusion_on && !p->scan_film;
  gtk_widget_set_sensitive(g->print_diffusion_filter_family, pdif);
  gtk_widget_set_sensitive(g->print_diffusion_strength, pdif);
  gtk_widget_set_sensitive(g->print_diffusion_scale, pdif);
  gtk_widget_set_sensitive(g->print_diffusion_warmth, pdif);

  /* last: the development sliders are gated on their own stock's family, and
     must not be re-enabled by any of the plain `printing` toggles above */
  _update_development_sensitivity(g, p);
}

void gui_reset(dt_iop_module_t *self)
{
  dt_iop_color_picker_reset(self, TRUE);
}

/* called by the core whenever a params-linked widget changed */
void gui_changed(dt_iop_module_t *self,
                 GtkWidget *w,
                 void *previous)
{
  _stamp_lut_hash(self);

  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;
  /* `w` is always a real widget. dt_iop_gui_changed() is reached only from
     bauhaus's value-changed handlers, each passing the widget and a pointer to
     the field's previous value, and nothing in the core calls it to refresh a
     module's dependent widgets -- gui_update() calls the sensitivity helpers
     directly for that.

     boost_ev is in this list for the same reason the master toggles are: it
     gates the two sliders under it. */
  if(w == g->scan_black_correction || w == g->scan_white_correction)
    _update_print_sensitivity(self);
  if(w == g->halation_on || w == g->grain_on || w == g->diffusion_on
     || w == g->print_diffusion_on || w == g->boost_ev)
  {
    _toggle_sensitivity(g, p);
    if(w == g->print_diffusion_on) _update_print_sensitivity(self);
  }
  if(w == g->print_auto_exposure && !*(gboolean *)previous && p->print_auto_exposure)
  {
    /* print_exposure_ev (manual) and print_auto_exposure (automatic) are
       independent, always-additive factors -- matching the reference app's
       own architecture (raw *= exposure_factor; raw *= enlarger.print_exposure,
       two separate multiplications), not a mutually-exclusive pair.
       Left alone, re-enabling auto stacks on top of whatever manual EV was
       dialed in while it was off, which reads as "auto exposure is now
       offset by the old manual value". Reset the manual slider on OFF->ON
       so re-enabling auto gives a clean auto result to fine-tune from. */
    p->print_exposure_ev = 0.0f;
    dt_bauhaus_slider_set(g->print_exposure_ev, 0.0f);
  }
}

/* ---------------------------------------------------------------------- */
/* data pack status row                                                   */
/* ---------------------------------------------------------------------- */

static gboolean _data_poll_cb(gpointer user_data);

/* Reflect pack state into the header row. Called whenever the module's trouble
   state is refreshed and once per second while a download runs. */
static void _update_data_row(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  const dt_iop_spektrafilm_params_t *p =
      (const dt_iop_spektrafilm_params_t *)self->params;
  if(!g || !g->data_box) return;

  char msg[256] = { 0 };
  double progress = 0.0;
  const sf_fetch_state_t state = sf_fetch_status(msg, sizeof msg, &progress);

  /* Is any pack usable at all, and is it the one this edit was made with?
     Local-only, so this is cheap enough to answer on every refresh. */
  char dir[SF_PATH_LEN];
  uint32_t want_table = 0;
  const sf_edit_pack_t st = _edit_pack_dir(p, dir, sizeof dir, &want_table);
  /* the controls stay whenever something renders. A substitute renders too;
     it is what the row below offers a way out of, not a reason to hide the
     edit's own settings */
  const gboolean have_any = st != SF_EDIT_PACK_NONE;
  const gboolean exact = st == SF_EDIT_PACK_OK;

  /* With no pack there is nothing any of the controls could act on: a film
     list with no films, sliders driving a simulation that cannot be built.
     Collapse the module to the one control that changes that, and bring the
     rest back only once a pack is in place. */
  if(g->main_box) gtk_widget_set_visible(g->main_box, have_any);

  if(state == SF_FETCH_RUNNING)
  {
    /* started from the update row, which reports its own progress. Recorded
       before returning all the same: the running-to-done transition below is
       what rebuilds the GUI once a pack lands, and a fetch that never looked
       like it was running never looks like it finished either: the module
       would keep the table list it read before the download */
    if(g->update_fetching)
    {
      g->data_last_state = state;
      gtk_widget_set_visible(g->data_box, FALSE);
      if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
      return;
    }
    g->data_last_state = state;
    /* Percentage first. The message that follows it is a filename and a file
       count, which grows and shrinks as the download moves between files, so a
       trailing percentage sits at a different place on every refresh and has to
       be hunted for. Leading, it is always in the same column. Progress is -1
       while a step has no measurable fraction, in which case there is nothing
       to put in front and the message stands alone. */
    char line[320];
    if(progress >= 0.0)
      snprintf(line, sizeof line, "%3d%%  %s", (int)(progress * 100.0 + 0.5), msg);
    else
      g_strlcpy(line, msg, sizeof line);
    gtk_label_set_text(GTK_LABEL(g->data_status), line);
    gtk_button_set_label(GTK_BUTTON(g->data_button), _("cancel"));
    gtk_widget_set_sensitive(g->data_button, TRUE);
    gtk_widget_set_visible(g->data_box, TRUE);
    if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
    return;
  }

  if(g->data_poll)
  {
    g_source_remove(g->data_poll);
    g->data_poll = 0;
  }

  /* A fetch just finished. Reprocessing alone is not enough to make the new
     pack usable: the film and paper comboboxes were filled by _rescan() at a
     time when no profiles existed, and nothing refills them on a pipe
     reprocess. Without this the module renders but every stock list stays
     empty, so no selection can be made and changing a slider appears to do
     nothing. dt_iop_gui_update() re-runs gui_update(), which rescans. */
  if(g->data_last_state == SF_FETCH_RUNNING && state == SF_FETCH_DONE
     && sf_fetch_generation() != g->data_gen)
  {
    g->data_last_state = state;
    g->data_gen = sf_fetch_generation();
    dt_iop_gui_update(self);

    /* Drop the cached pipeline output from this module onwards before asking
       for a reprocess.

       The pixelpipe caches by a hash over module parameters, and installing a
       pack changes none of them -- so the cacheline computed while the module
       had no data (and therefore passed pixels through untouched) still
       matches and gets reused. The symptom is a module that stays inert after
       a successful download, starts working the moment any slider moves, and
       goes inert again the instant that slider returns to its original value,
       because that value hashes back onto the stale line. */
    dt_develop_t *dev = darktable.develop;
    if(dev)
    {
      dt_dev_pixelpipe_t *pipes[]
          = { dev->full.pipe, dev->preview_pipe, dev->preview2.pipe };
      for(size_t i = 0; i < sizeof(pipes) / sizeof(pipes[0]); i++)
        if(pipes[i])
          dt_dev_pixelpipe_cache_invalidate_later(pipes[i], self->iop_order,
                                                  "spektrafilm data pack installed");
      dt_dev_reprocess_all(dev);
    }
    return; /* gui_update() calls back into here with the settled state */
  }
  g->data_last_state = state;

  /* Nothing installed at all, or installed but not the pack this edit wants.
     Those are the only two states worth offering a download for; anything else
     leaves the row hidden. */
  if(exact)
  {
    gtk_widget_set_visible(g->data_box, FALSE);
    return;
  }

  /* the table rather than 0 when the edit names one: the manifest is ordered
     oldest first, so asking for the table lands on the pack the edit was made
     with, where asking for nothing lands on whatever is default */
  g->data_wanted = want_table;
  g->data_wanted_pack = p->pack_hash;
  gtk_label_set_text(
      GTK_LABEL(g->data_status),
      have_any
          ? _("the data pack this edit was developed with is not installed")
          : _("no data pack installed -- the module's controls appear once one is"));
  /* a failed download says why in a toast that is gone in seconds; keep the
     reason here, beside the button that retries it, until the next attempt */
  {
    char why[256] = { 0 };
    if(sf_fetch_status(why, sizeof why, NULL) == SF_FETCH_FAILED && why[0])
    {
      gchar *both = g_strdup_printf("%s\n%s",
                                    gtk_label_get_text(GTK_LABEL(g->data_status)), why);
      gtk_label_set_text(GTK_LABEL(g->data_status), both);
      g_free(both);
    }
  }
  gtk_button_set_label(GTK_BUTTON(g->data_button), _("download data pack"));
  gtk_widget_set_tooltip_text(g->data_button,
                              _("fetch the matching spectral data pack over the network"));
  gtk_widget_set_visible(g->data_box, TRUE);
}

/* the update row, which asks a question the module cannot answer on its own:
   whether the repository has moved on. Separate from the data row above, which
   reports a pack missing right now: that belongs at the top of the module
   because nothing renders without it, while this is housekeeping.

   Offered rather than run on its own: it is network traffic nobody asked for,
   and a pack fetched here is never adopted behind an existing edit. It
   installs beside the one in use, and only a fresh edit or a deliberate table
   choice renders on it */
static void _update_update_row(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  if(!g || !g->update_box) return;

  char msg[256] = { 0 };
  double progress = -1.0;
  const sf_fetch_state_t state = sf_fetch_status(msg, sizeof msg, &progress);
  if(g->update_fetching && state == SF_FETCH_RUNNING)
  {
    /* reported here rather than in the row at the top of the module: that one
       is about a pack missing right now, and a check or an update fetch is
       neither missing nor urgent */
    gtk_label_set_text(GTK_LABEL(g->update_status), msg);
    gtk_button_set_label(GTK_BUTTON(g->update_button), _("cancel"));
    if(progress >= 0.0)
      gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(g->update_progress), progress);
    else
      gtk_progress_bar_pulse(GTK_PROGRESS_BAR(g->update_progress));
    gtk_widget_set_visible(g->update_progress, TRUE);
    return;
  }
  gtk_widget_set_visible(g->update_progress, FALSE);
  if(g->update_fetching && state != SF_FETCH_RUNNING)
  {
    g->update_fetching = FALSE;
    if(state == SF_FETCH_FAILED)
      g_strlcpy(g->update_error, msg[0] ? msg : _("the data repository could not be reached"),
                sizeof g->update_error);
  }

  const uint32_t avail = sf_fetch_available_pack();
  char ver[32];
  sf_fetch_available_version(ver, sizeof ver);
  /* the pack the check found may since have been fetched, and nothing clears
     avail_pack on install: ask the disk rather than keep offering it */
  char installed_at[SF_PATH_LEN] = { 0 };
  const gboolean now_installed =
      avail && sf_fetch_pack_dir_for_pack_hash(avail, installed_at,
                                               sizeof installed_at);

  char label[96], status[96];
  if(avail && ver[0])
  {
    g_snprintf(label, sizeof label, _("download data pack %s"), ver);
    g_snprintf(status, sizeof status, _("data pack %s is available"), ver);
  }
  else
  {
    g_strlcpy(label, _("download data pack"), sizeof label);
    g_strlcpy(status, _("a newer data pack is available"), sizeof status);
  }

  char done_msg[96];
  if(now_installed && ver[0])
    g_snprintf(done_msg, sizeof done_msg, _("data pack %s installed"), ver);
  else
    g_strlcpy(done_msg, _("data pack installed"), sizeof done_msg);

  gtk_label_set_text(GTK_LABEL(g->update_status),
                     g->update_error[0] ? g->update_error
                     : now_installed    ? done_msg
                     : avail            ? status
                     : g->data_checked
                         ? _("the installed data packs are up to date")
                         : "");
  gtk_button_set_label(GTK_BUTTON(g->update_button),
                       (avail && !now_installed) ? label
                                                 : _("check for data pack updates"));
  gtk_widget_set_tooltip_text(
      g->update_button,
      (avail && !now_installed)
          ? _("fetch the newer pack. it installs beside the one you have;\n"
              "existing edits keep rendering on the pack they were made with.")
          : _("ask the data repository whether a newer pack is published"));
}

static void _update_button_clicked(GtkButton *button,
                                   dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  if(!g) return;
  g->update_error[0] = 0;
  /* the pack the last check found stays recorded after it is installed, and
     the row then offers a check again; downloading it once more would answer
     the button with the wrong action */
  const uint32_t avail = sf_fetch_available_pack();
  char installed_at[SF_PATH_LEN];
  const gboolean avail_missing
      = avail && !sf_fetch_pack_dir_for_pack_hash(avail, installed_at, sizeof installed_at);
  if(sf_fetch_status(NULL, 0, NULL) == SF_FETCH_RUNNING)
    sf_fetch_cancel();
  else if(avail_missing)
  {
    g->update_fetching = TRUE;
    sf_fetch_start(0u, avail);
  }
  else
  {
    /* set whether or not the check finds anything: it is what lets the row say
       "up to date" rather than stay blank, which before a check has run would
       claim knowledge it does not have */
    g->data_checked = TRUE;
    g->update_fetching = TRUE;
    sf_fetch_check_start();
  }
  /* the worker answers on its own thread, so the row this call paints is the
     one from before it started. Poll until it finishes, exactly as a download
     does: _update_data_row() clears the timer once the fetch stops running,
     and _data_poll_cb repaints both rows on every tick. Without this the row
     keeps the answer it had when the button was pressed */
  if(!g->data_poll) g->data_poll = g_timeout_add(500, _data_poll_cb, self);
  _update_update_row(self);
}

static gboolean _data_poll_cb(gpointer user_data)
{
  dt_iop_module_t *self = (dt_iop_module_t *)user_data;
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  if(!g || !g->data_box) return G_SOURCE_REMOVE;
  _update_data_row(self);
  _update_update_row(self);
  /* _update_data_row clears data_poll once the fetch stops running, which is
     also the signal to stop this timeout. */
  return g->data_poll ? G_SOURCE_CONTINUE : G_SOURCE_REMOVE;
}

static void _data_button_clicked(GtkButton *button,
                                 dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  if(!g) return;

  if(sf_fetch_status(NULL, 0, NULL) == SF_FETCH_RUNNING)
    sf_fetch_cancel();
  else
    sf_fetch_start(g->data_wanted, g->data_wanted_pack);

  _update_data_row(self);
}

/* sim_error and sim_warning reach the user through the module's trouble
   banner, darktable's own mechanism for this: a missing data pack, an
   unreadable profile or a spectral-table mismatch would otherwise show only as
   a blank or silently wrong render.

   Refresh the banner from the main loop, posted by _ensure_sim() when the
   status changed. Rechecks gui_data, since the module can be collapsed or the
   darkroom left between the pipe run and this firing. */
static gboolean _trouble_idle_cb(gpointer user_data)
{
  dt_iop_module_t *self = (dt_iop_module_t *)user_data;
  dt_iop_spektrafilm_gui_data_t *g = self ? self->gui_data : NULL;
  if(!g) return G_SOURCE_REMOVE;
  /* Clear the id first: gui_cleanup() removes the source by it, and once this
     callback is running there is nothing left to remove. */
  dt_iop_gui_enter_critical_section(self);
  g->trouble_idle = 0;
  dt_iop_gui_leave_critical_section(self);
  _update_trouble_message(self);
  return G_SOURCE_REMOVE;
}

static void _update_trouble_message(dt_iop_module_t *self)
{
  const dt_iop_spektrafilm_gui_data_t *g =
      (const dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  _update_data_row(self);
  if(!g) return;
  /* Snapshot under the same section _publish_status() writes in: the writer is
     a pixelpipe thread and this runs on GTK, so reading the arrays directly
     races the copy. */
  char err[sizeof g->status_error], warn[sizeof g->status_warning];
  dt_iop_gui_enter_critical_section(self);
  g_strlcpy(err, g->status_error, sizeof err);
  g_strlcpy(warn, g->status_warning, sizeof warn);
  dt_iop_gui_leave_critical_section(self);
  if(err[0])
    dt_iop_set_module_trouble_message(self, _("cannot render"), err, NULL);
  else if(warn[0])
    dt_iop_set_module_trouble_message(self, _("data mismatch"), warn, NULL);
  else
    dt_iop_set_module_trouble_message(self, NULL, NULL, NULL);
}

/* --- generated presets: begin --- */

/* The starting point every shipped preset is built from: a zeroed struct with
   each non-zero introspection default written back, so a preset below only has
   to name the parameters its own look moves.

   Must list every field whose $DEFAULT is not zero: a field added to
   dt_iop_spektrafilm_params_t and forgotten here ships in all 17 presets at
   zero, which for a boolean defaulting to TRUE means the preset silently turns
   the feature off.

   The values are the presets' baseline, not a mirror of the annotations. They
   track $DEFAULT wherever a preset has no opinion, but a preset was tuned
   against the values in force when it was authored, so a later change to a
   $DEFAULT does not propagate here -- see grain_blur_base below. Never
   "resynchronise" this function against the struct without rendering the
   presets that leave the field unset. */
static void _preset_defaults(dt_iop_spektrafilm_params_t *p)
{
  memset(p, 0, sizeof(*p));
  p->print_contrast = 1.0f;
  p->print_gamma_r = p->print_gamma_g = p->print_gamma_b = 1.0f;
  /* deliberately 0.8, not the 0.89 $DEFAULT: the presets were authored against
     it, and none of them names grain_blur_base to say otherwise */
  p->grain_blur_base = 0.8f;
  p->upsampling_hash = 0u; /* the pack's default table */
  p->pack_hash = 0u;       /* resolve by table, as a preset must */
  /* off, as the printing presets were authored; the scan presets switch
     them on with scan_film, as choosing a positive stock does */
  p->scan_black_correction = p->scan_white_correction = FALSE;
  p->scan_black_level = 0.01f;
  p->scan_white_level = 0.98f;
  p->couplers_amount = 1.0f;
  p->couplers_diffusion_um = 20.0f;
  p->couplers_tail_um = 200.0f;
  p->couplers_tail_weight = 0.03f;
  p->couplers_inhibition_same = 1.0f;
  p->couplers_inhibition_inter = 1.0f;
  p->halation_on = TRUE;
  p->scatter_amount = 1.0f;
  p->scatter_scale = 1.0f;
  p->halation_amount = 1.0f;
  p->halation_scale = 1.0f;
  p->boost_range = 0.3f;
  p->protect_ev = 4.0f;
  p->diffusion_strength = 0.5f;
  p->diffusion_scale = 1.0f;
  p->print_diffusion_strength = 0.5f;
  p->print_diffusion_scale = 1.0f;
  p->grain_on = TRUE;
  p->grain_amount = 1.0f;
  p->grain_blur = 1.0f;
  p->grain_granularity = 1.0f;
  p->grain_uniformity = 1.0f;
  p->grain_sublayer_scale = 1.0f;
  p->grain_density_min = 1.0f;
  p->grain_dye_cloud = 1.0f;
  p->film_format_mm = 36.0f;
  p->output_luminance_boost = 1.0f;
  p->output_scale = 1.0f;
  p->grain_usm_sigma = 0.7f;
  p->grain_usm_amount = 1.5f;
  p->film_gamma_factor = 1.0f;
  p->film_gamma_factor_fast = 1.0f;
  p->film_gamma_factor_slow = 1.0f;
  p->scan_usm_sigma = 0.7f;
  p->scan_usm_amount = 0.7f;
  p->adaptation_bandwidth = TRUE;
  p->gamut_compress = TRUE;
}

void init_presets(dt_iop_module_so_t *self)
{
  /* The scene-referred preset below exists only under one workflow, so the
     built-in presets have to be rebuilt when that setting changes. Without this
     flag _iop_preferences_changed() skips the module and the preset is created
     only if the workflow already happened to be spektrafilm when the module was
     first loaded -- selecting it later then does nothing until the library is
     rebuilt. filmicrgb, sigmoid and agx all set it for the same reason. */
  self->pref_based_presets = TRUE;

  dt_iop_spektrafilm_params_t p;

  /* The module is a view transform, so it takes the place of filmic, sigmoid or
     agx and not a place beside them. Selecting "scene-referred (spektrafilm)"
     auto-applies this preset and leaves those three unapplied, each of them
     already testing for its own workflow by name.

     Everything else the scene-referred workflow does still happens:
     dt_is_scene_referred() counts this value, so exposure and colour
     calibration are applied as usual. The film needs a correctly placed middle
     grey to land on its curve, so removing exposure would work against the
     module as much as against the workflow.

     Defaults, with no stock named: film_hash 0 resolves to the pack's first
     filming stock, which keeps the preset working whatever pack is installed.
     FOR_RAW | FOR_MATRIX matches the other view transforms -- a scene-referred
     starting point needs a camera the input profile can place. */
  const char *workflow = dt_conf_get_string_const("plugins/darkroom/workflow");
  if(!g_strcmp0(workflow, "scene-referred (spektrafilm)"))
  {
    _preset_defaults(&p);
    /* The one preset that is a startup value rather than a look: it is
       auto-applied to every new raw under this workflow, so it -- not the
       introspection default -- is what a fresh image opens with. Fields whose
       $DEFAULT is meant to reach new images are therefore named here as well,
       since _preset_defaults() holds the baseline the shipped looks were
       authored against and does not track later changes to the annotations. */
    p.grain_blur_base = 0.89f;
    dt_gui_presets_add_generic(_("scene-referred default"), self->op,
                               self->version(), &p, sizeof(p), TRUE,
                               DEVELOP_BLEND_CS_RGB_SCENE);
    dt_gui_presets_update_format(BUILTIN_PRESET("scene-referred default"),
                                 self->op, self->version(),
                                 FOR_RAW | FOR_MATRIX);
    dt_gui_presets_update_autoapply(BUILTIN_PRESET("scene-referred default"),
                                    self->op, self->version(), TRUE);
  }

  /* look|crisp black and white -- by Bazdesh, shared as "BW_Crispy TriX"

     Tri-X pushed a stop and a third onto a hard slow layer, scanner
     sharpening well up, acutance recovery off so the grain stays as drawn. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_trix");
  p.exposure_ev = 1.05f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 30.0f;
  p.scan_film = TRUE;
  p.scan_black_correction = p.scan_white_correction = TRUE;
  p.halation_amount = 2.0f;
  p.halation_scale = 4.0f;
  p.diffusion_on = TRUE;
  p.diffusion_strength = 0.375f;
  p.grain_amount = 0.7f;
  p.grain_blur = 0.75f;
  p.grain_granularity = 0.7f;
  p.grain_uniformity = 0.9f;
  p.film_format_mm = 24.0f;
  p.output_luminance_boost = 1.3f;
  p.output_scale = 1.3f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.9f;
  p.film_gamma_factor_fast = 1.3f;
  p.film_gamma_factor_slow = 2.0f;
  p.push_pull_stops = 1.3f;
  p.scan_usm_sigma = 1.7f;
  p.scan_usm_amount = 1.0f;
  p.adaptation_bandwidth = FALSE;
  dt_gui_presets_add_generic(_("look|crisp black and white"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|black and white -- by Bazdesh, shared as "BW_Low-cal TriX"

     The same stock pulled instead: flat fast and slow layers under a raised
     overall gamma, lifted on the output scale rather than in development. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_trix");
  p.exposure_ev = -0.7f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 30.0f;
  p.scan_film = TRUE;
  p.scan_black_correction = p.scan_white_correction = TRUE;
  p.grain_blur = 0.75f;
  p.grain_granularity = 0.4f;
  p.output_scale = 1.65f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 1.7f;
  p.film_gamma_factor_fast = 0.4f;
  p.film_gamma_factor_slow = 0.6f;
  p.push_pull_stops = -0.7f;
  p.scan_usm_sigma = 1.7f;
  p.scan_usm_amount = 1.0f;
  p.adaptation_bandwidth = FALSE;
  dt_gui_presets_add_generic(_("film|black and white"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|split-toned -- by Bazdesh, shared as "BW_Split-toned"

     Double-X printed onto colour paper, which is where the split tone comes
     from; the M/Y filtration sets which way each end of the scale leans. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_doublex");
  p.paper_hash = _name_hash("fujifilm_crystal_archive_typeii");
  p.exposure_ev = -0.7f;
  p.filter_m = 5.0f;
  p.filter_y = -3.0f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 30.0f;
  p.preflash_exposure = 0.03f;
  p.diffusion_on = TRUE;
  p.diffusion_strength = 0.25f;
  p.grain_blur = 0.75f;
  p.grain_granularity = 0.5f;
  p.output_scale = 1.2f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 1.7f;
  p.film_gamma_factor_fast = 0.5f;
  p.film_gamma_factor_slow = 0.7f;
  p.push_pull_stops = -1.0f;
  p.scan_usm_sigma = 1.7f;
  p.scan_usm_amount = 1.0f;
  p.glare_percent = 0.03f;
  p.development_min = 6.5f;
  p.adaptation_bandwidth = FALSE;
  dt_gui_presets_add_generic(_("look|split-toned"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|bleach bypass -- by Boris Hajdukovic, shared as "bleach bypass
     ryan"

     Silver retention emulated by driving same-layer inhibition to maximum
     and interlayer to zero: black-and-white contrast under a muted palette. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_vision3_200t");
  p.paper_hash = _name_hash("kodak_2393");
  p.exposure_ev = 1.39f;
  p.filter_m = 4.43f;
  p.filter_y = -0.15f;
  p.couplers_amount = 0.99f;
  p.couplers_inhibition_same = 3.0f;
  p.couplers_inhibition_inter = 0.0f;
  p.preflash_m_shift = -1.07f;
  p.preflash_y_shift = -0.76f;
  p.diffusion_on = TRUE;
  p.diffusion_filter_family = DT_SPEKTRAFILM_DIFF_CINEBLOOM;
  p.diffusion_strength = 0.183f;
  p.diffusion_scale = 4.0f;
  p.diffusion_warmth = 0.01f;
  p.film_format_mm = 24.89f;
  p.film_gamma_factor = 0.72f;
  p.film_gamma_factor_fast = 0.52f;
  p.film_gamma_factor_slow = 1.69f;
  p.push_pull_stops = 1.25f;
  p.scan_usm_sigma = 2.73f;
  dt_gui_presets_add_generic(_("look|bleach bypass"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|cinema -- by Cristian, shared as "Kodak Vision3 250D"

     The same stock left on its own print target and steered cool-magenta
     through filtration and a strongly shifted preflash. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_vision3_250d");
  p.exposure_ev = 0.26f;
  p.filter_m = -3.75f;
  p.filter_y = -7.5f;
  p.couplers_inhibition_same = 1.93f;
  p.couplers_inhibition_inter = 0.5f;
  p.preflash_exposure = 0.02f;
  p.preflash_m_shift = -30.22f;
  p.preflash_y_shift = 35.74f;
  p.output_luminance_boost = 1.19f;
  dt_gui_presets_add_generic(_("film|cinema"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|kino 250D -- by Bazdesh, shared as "Kino250D"

     Vision3 250D on 2383 print stock, pulled and lifted hard on the pre-
     compression boost, with wide halation and a cool diffusion halo. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_vision3_250d");
  p.paper_hash = _name_hash("kodak_2383");
  p.exposure_ev = -0.7f;
  p.print_contrast = 1.2f;
  p.filter_m = -3.0f;
  p.filter_y = -4.5f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 30.0f;
  p.couplers_inhibition_same = 0.7f;
  p.couplers_inhibition_inter = 0.6f;
  p.preflash_exposure = 0.03f;
  p.preflash_m_shift = 5.0f;
  p.preflash_y_shift = 10.0f;
  p.halation_amount = 2.0f;
  p.halation_scale = 4.0f;
  p.diffusion_on = TRUE;
  p.diffusion_strength = 0.125f;
  p.diffusion_scale = 0.5f;
  p.diffusion_warmth = -1.5f;
  p.grain_amount = 0.5f;
  p.grain_granularity = 0.9f;
  p.output_luminance_boost = 1.9f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.9f;
  p.film_gamma_factor_fast = 0.7f;
  p.film_gamma_factor_slow = 1.1f;
  p.push_pull_stops = -0.85f;
  p.scan_usm_sigma = 1.4f;
  p.scan_usm_amount = 0.8f;
  dt_gui_presets_add_generic(_("look|kino 250D"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|classic -- by Cristian, shared as "Kodachrome 64"

     The gentler Kodachrome: no push, the separation carried by strong same-
     layer and weak interlayer inhibition instead of by contrast. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_kodachrome_64");
  p.couplers_diffusion_um = 23.6f;
  p.couplers_inhibition_same = 1.54f;
  p.couplers_inhibition_inter = 0.3f;
  p.scan_film = TRUE;
  p.scan_black_correction = p.scan_white_correction = TRUE;
  p.film_gamma_factor_slow = 1.32f;
  dt_gui_presets_add_generic(_("film|classic"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);
  /* film|contrast -- by Roberto Abella, shared as "C-200 [Contrast]
     [Best for: Color Calibrated - ETTL]"

     C200 on Endura Premier at a stop over, half-strength couplers and
     doubled grain under strong viewing glare. Expects a calibrated, right-
     biased exposure. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("fujifilm_c200");
  p.paper_hash = _name_hash("kodak_endura_premier");
  p.exposure_ev = 1.05f;
  p.couplers_amount = 0.5f;
  p.grain_amount = 2.0f;
  p.film_format_mm = 24.0f;
  p.film_gamma_factor = 0.9f;
  p.film_gamma_factor_fast = 0.8f;
  p.glare_percent = 0.75f;
  p.adaptation_surface = TRUE;
  dt_gui_presets_add_generic(_("film|contrast"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|neutral -- by Roberto Abella, shared as "X-Tra 400 [00] [Best
     for: Color Calibrated - well exposed]"

     X-Tra 400 on its own print target, half-strength couplers, lifted on
     both sides of the gamut compressor. Expects a calibrated, well exposed
     frame. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("fujifilm_xtra_400");
  p.couplers_amount = 0.5f;
  p.output_luminance_boost = 1.5f;
  p.output_scale = 1.2056f;
  p.glare_percent = 0.25f;
  p.adaptation_surface = TRUE;
  dt_gui_presets_add_generic(_("film|neutral"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|soft pastel -- by Bazdesh, shared as "Pastel C200"

     C200 preflashed hard toward green, printed through a tight print-side
     diffusion filter, grain coarsened to sit on top of the flattened colour. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("fujifilm_c200");
  p.paper_hash = _name_hash("fujifilm_crystal_archive_typeii");
  p.exposure_ev = 1.0f;
  p.print_exposure_ev = -0.2f;
  p.filter_m = 5.0f;
  p.filter_y = -1.0f;
  p.couplers_amount = 0.75f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 15.0f;
  p.couplers_inhibition_inter = 0.9f;
  p.preflash_exposure = 0.05f;
  p.preflash_m_shift = -23.5f;
  p.preflash_y_shift = -30.0f;
  p.halation_scale = 3.0f;
  p.print_diffusion_on = TRUE;
  p.print_diffusion_strength = 0.75f;
  p.print_diffusion_scale = 0.5f;
  p.grain_amount = 1.3f;
  p.grain_granularity = 1.7f;
  p.output_luminance_boost = 1.25f;
  p.output_scale = 1.05f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.9f;
  p.film_gamma_factor_fast = 0.9f;
  p.film_gamma_factor_slow = 1.7f;
  p.push_pull_stops = 0.8f;
  p.scan_usm_sigma = 1.2f;
  p.scan_usm_amount = 1.0f;
  dt_gui_presets_add_generic(_("film|soft pastel"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|warm -- by Cristian, shared as "Kodak Portra 800"

     Portra 800 pulled slightly, warmed through magenta filtration and an
     offsetting preflash, then lifted on the pre-compression boost. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_portra_800");
  p.exposure_ev = 0.53f;
  p.print_exposure_ev = 0.13f;
  p.filter_m = 10.37f;
  p.filter_y = -2.87f;
  p.couplers_inhibition_same = 1.51f;
  p.couplers_inhibition_inter = 0.81f;
  p.preflash_exposure = 0.04f;
  p.preflash_m_shift = -28.46f;
  p.preflash_y_shift = -33.97f;
  p.output_luminance_boost = 1.64f;
  p.film_gamma_factor_slow = 1.32f;
  p.push_pull_stops = -0.32f;
  dt_gui_presets_add_generic(_("film|warm"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|portrait -- by Roberto Abella, shared as "Kodak Portra 400
     [Bright - Endura Premier - No Glare] [Best for: slightly ETTL]"

     Portra 400 on Endura Premier, exposed well up with soft fast and slow
     layers and half the developer exhausted. Expects a slightly right-biased
     exposure. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_portra_400");
  p.paper_hash = _name_hash("kodak_endura_premier");
  p.exposure_ev = 1.4f;
  p.couplers_amount = 0.3f;
  p.output_luminance_boost = 1.3f;
  p.output_scale = 1.3f;
  p.film_gamma_factor_fast = 0.64f;
  p.film_gamma_factor_slow = 0.44f;
  p.film_developer_exhaustion = 0.5f;
  p.adaptation_surface = TRUE;
  dt_gui_presets_add_generic(_("film|portrait"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* film|portrait contrast -- by Roberto Abella, shared as "Kodak Portra 400
     [Contrast - Endura Premier - Glare] [Best for: Color Calibrated - well
     exposed]"

     The same pairing left at box exposure, its contrast coming from
     exhausted development and a heavy viewing-glare simulation. Expects a
     calibrated, well exposed frame. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_portra_400");
  p.paper_hash = _name_hash("kodak_endura_premier");
  p.couplers_amount = 0.25f;
  p.film_developer_exhaustion = 0.45f;
  p.glare_percent = 0.82f;
  dt_gui_presets_add_generic(_("film|portrait contrast"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|vivid -- by Bazdesh, shared as "Velveetia"

     Velvia 100 scanned direct, with the interlayer inhibition pulled back so
     the saturation the stock is known for survives the push. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("fujifilm_velvia_100");
  p.exposure_ev = 0.9f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 15.0f;
  p.couplers_inhibition_same = 1.2f;
  p.couplers_inhibition_inter = 0.6f;
  p.scan_film = TRUE;
  p.scan_black_correction = p.scan_white_correction = TRUE;
  p.halation_scale = 4.0f;
  p.output_scale = 1.3f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.8f;
  p.film_gamma_factor_fast = 1.1f;
  p.film_gamma_factor_slow = 2.0f;
  p.push_pull_stops = 0.7f;
  p.scan_usm_sigma = 1.5f;
  p.scan_usm_amount = 1.0f;
  p.adaptation_bandwidth = FALSE;
  dt_gui_presets_add_generic(_("look|vivid"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|skodachrome -- by Bazdesh, shared as "Skodachrome"

     Kodachrome 64 scanned rather than printed, pushed and steepened in the
     slow layer: the punchiest of the three reversal looks. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_kodachrome_64");
  p.exposure_ev = 1.1f;
  p.couplers_amount = 0.6f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 15.0f;
  p.couplers_inhibition_inter = 0.6f;
  p.scan_film = TRUE;
  p.scan_black_correction = p.scan_white_correction = TRUE;
  p.halation_scale = 4.0f;
  p.output_scale = 1.3f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.8f;
  p.film_gamma_factor_fast = 1.1f;
  p.film_gamma_factor_slow = 2.0f;
  p.push_pull_stops = 0.8f;
  p.scan_usm_sigma = 1.5f;
  p.scan_usm_amount = 1.0f;
  p.adaptation_bandwidth = FALSE;
  dt_gui_presets_add_generic(_("look|skodachrome"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|foliage -- by Bazdesh, shared as "Gold200_foliage"

     Gold 200 on Endura Premier with the print contrast up and heavy yellow
     filtration, aimed at green subjects. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_gold_200");
  p.paper_hash = _name_hash("kodak_endura_premier");
  p.exposure_ev = -0.2f;
  p.print_contrast = 1.3f;
  p.filter_m = 4.5f;
  p.filter_y = 9.5f;
  p.couplers_amount = 0.85f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 20.0f;
  p.preflash_exposure = 0.03f;
  p.diffusion_strength = 0.125f;
  p.diffusion_scale = 0.5f;
  p.diffusion_warmth = -1.5f;
  p.grain_granularity = 1.1f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.9f;
  p.film_gamma_factor_fast = 0.9f;
  p.film_gamma_factor_slow = 1.1f;
  p.push_pull_stops = 0.45f;
  p.scan_usm_sigma = 1.2f;
  p.adaptation_bandwidth = FALSE;
  dt_gui_presets_add_generic(_("look|foliage"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);

  /* look|glow -- by Bazdesh, shared as "KinoGolden"

     Gold 200 on Ultra Endura, pushed half a stop into warm filtration, with
     the wide halation and cool diffusion halo of the cinema pair. */
  _preset_defaults(&p);
  p.film_hash = _name_hash("kodak_gold_200");
  p.paper_hash = _name_hash("kodak_ultra_endura");
  p.exposure_ev = 0.45f;
  p.filter_m = 4.0f;
  p.filter_y = -5.0f;
  p.couplers_diffusion_um = 5.0f;
  p.couplers_tail_um = 30.0f;
  p.couplers_inhibition_same = 0.7f;
  p.couplers_inhibition_inter = 0.6f;
  p.preflash_exposure = 0.03f;
  p.preflash_m_shift = 0.5f;
  p.halation_amount = 2.0f;
  p.halation_scale = 4.0f;
  p.diffusion_on = TRUE;
  p.diffusion_strength = 0.125f;
  p.diffusion_scale = 0.5f;
  p.diffusion_warmth = -1.5f;
  p.grain_granularity = 1.1f;
  p.output_luminance_boost = 1.2f;
  p.output_scale = 1.3f;
  p.grain_usm_sigma = 0.0f;
  p.grain_usm_amount = 0.0f;
  p.film_gamma_factor = 0.9f;
  p.film_gamma_factor_fast = 0.9f;
  p.film_gamma_factor_slow = 1.1f;
  p.push_pull_stops = 0.5f;
  p.scan_usm_sigma = 1.2f;
  dt_gui_presets_add_generic(_("look|glow"), self->op,
                             self->version(), &p, sizeof(p), TRUE,
                             DEVELOP_BLEND_CS_RGB_SCENE);
}

/* --- generated presets: end --- */

void gui_update(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;
  dt_iop_spektrafilm_params_t *p = (dt_iop_spektrafilm_params_t *)self->params;

  _rescan(self);
  _update_update_row(self);

  /* Films and papers share one list; e->printing separates them, and each
     combobox entry carries its position in that list as its data. */
  static const struct { int pos; int bw; const char *label; } groups[] = {
    { 0, 0, N_("negative color") },
    { 0, 1, N_("negative monochrome") },
    { 1, 0, N_("positive color") },
    { 1, 1, N_("positive monochrome") },
  };
  static const struct { int bw; const char *label; } pgroups[] = {
    { 0, N_("color") },
    { 1, N_("monochrome") },
  };

  dt_bauhaus_combobox_clear(g->film);
  gboolean any_film = FALSE;
  for(int gi = 0; gi < 4; gi++)
  {
    gboolean first = TRUE;
    int pos = 0;
    for(const GList *l = g->entries; l; l = l->next, pos++)
    {
      const sf_prof_entry_t *e = l->data;
      if(e->printing || e->positive != groups[gi].pos || e->bw != groups[gi].bw) continue;
      if(first) { dt_bauhaus_combobox_add_section(g->film, _(groups[gi].label)); first = FALSE; }
      dt_bauhaus_combobox_add_full(g->film, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                                   GINT_TO_POINTER(pos), NULL, TRUE);
      any_film = TRUE;
    }
  }
  /* Insensitive as well as carrying a sentinel: a placeholder describes the
     pack, it is not a choice, and _film_changed() has no stock to resolve. */
  if(!any_film)
    dt_bauhaus_combobox_add_full(g->film, _("(no profiles found)"),
                                 DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                                 GINT_TO_POINTER(SF_COMBO_NO_PROFILES), NULL, FALSE);

  dt_bauhaus_combobox_clear(g->paper);
  /* Two entries ahead of the stocks, both expressing a state rather than a
     paper, so the combobox alone can reach every configuration and no separate
     reset button is needed.

     "auto" (data -1) is paper_hash 0: follow the film's target print, the
     state a fresh edit starts in and the one _film_changed() keeps updating.
     Naming it in the list is what makes it selectable again after a specific
     paper has been picked.

     "none (scan the film)" (data -2) is scan_film: the pipeline treats "no
     print stage" and "scan the film" as one state, so it belongs in the same
     control. Both data values stay clear of the list positions used below. */
  dt_bauhaus_combobox_add_full(g->paper, _("none (scan the film)"),
                               DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                               GINT_TO_POINTER(SF_COMBO_SCAN_FILM), NULL, TRUE);
  /* ask the list where auto is about to land rather than assuming a position */
  g->paper_auto_pos = dt_bauhaus_combobox_length(g->paper);
  dt_bauhaus_combobox_add_full(g->paper, _("auto (follow film stock)"),
                               DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                               GINT_TO_POINTER(SF_COMBO_PAPER_AUTO), NULL, TRUE);
  gboolean any_paper = FALSE;
  for(int gi = 0; gi < 2; gi++)
  {
    gboolean first = TRUE;
    int pos = 0;
    for(const GList *l = g->entries; l; l = l->next, pos++)
    {
      const sf_prof_entry_t *e = l->data;
      if(!e->printing || e->bw != pgroups[gi].bw) continue;
      if(first) { dt_bauhaus_combobox_add_section(g->paper, _(pgroups[gi].label)); first = FALSE; }
      dt_bauhaus_combobox_add_full(g->paper, e->name, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                                   GINT_TO_POINTER(pos), NULL, TRUE);
      any_paper = TRUE;
    }
  }
  if(!any_paper)
    dt_bauhaus_combobox_add_full(g->paper, _("(none)"),
                                 DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                                 GINT_TO_POINTER(SF_COMBO_NO_PROFILES), NULL, FALSE);

  /* rebuild the spectral upsampling list from every installed pack */
  dt_bauhaus_combobox_clear(g->upsampling);
  {
    const int ntab = g_list_length(g->tables);
    /* shown even when the pack offers one table, so the control is where the
       user expects it and names what is in use: hiding it makes a pack that
       carries fewer tables than expected look like a build without the
       feature. Insensitive rather than absent when there is nothing to pick */
    gtk_widget_set_visible(g->upsampling, TRUE);
    gtk_widget_set_sensitive(g->upsampling, ntab > 1);
    int tpos = -1, ti = 0;
    for(const GList *l = g->tables; l; l = l->next, ti++)
    {
      const sf_table_entry_t *e = l->data;
      dt_bauhaus_combobox_add_full(g->upsampling, e->label, DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                                   GINT_TO_POINTER(ti), NULL, TRUE);
      if(p->upsampling_hash && e->hash == p->upsampling_hash) tpos = ti;
    }
    /* with no table chosen the edit renders on its pack's default, which is
       what lut_hash records once stamped. Position 0 is only the first pack's
       default, so it is the answer only when nothing is recorded. A chosen
       table that is not installed renders on the substitute's default and is
       reported by the banner */
    for(const GList *l = g->tables; tpos < 0 && p->lut_hash && l; l = l->next)
      if(((const sf_table_entry_t *)l->data)->hash == p->lut_hash)
        tpos = g_list_position(g->tables, (GList *)l);
    if(tpos < 0) tpos = 0;
    if(ntab) dt_bauhaus_combobox_set_from_value(g->upsampling, tpos);
    const sf_table_entry_t *sel = _table_at(g, tpos);
    _update_adaptation_sensitivity(g, sel);
  }

  /* Select the saved film. On no hash match -- a fresh param with film_hash 0,
     or a stock that vanished from the pack -- mirror _resolve_stock's fallback
     so the combobox agrees with what the pipeline actually renders, instead of
     landing on whatever sorts first while the pipe renders the real default. */
  int fpos = -1, fallback = -1, pos = 0;
  const sf_prof_entry_t *fe = NULL;
  for(const GList *l = g->entries; l; l = l->next, pos++)
  {
    const sf_prof_entry_t *e = l->data;
    if(e->printing) continue;
    if(fallback < 0 || !strcmp(e->stock, "kodak_gold_200")) fallback = pos;
    if(p->film_hash && e->hash == p->film_hash) { fpos = pos; fe = e; }
  }
  if(fpos < 0) fpos = fallback;
  if(fpos >= 0)
  {
    if(!fe) fe = _entry_at(g, fpos);
    dt_bauhaus_combobox_set_from_value(g->film, fpos);
  }

  /* p->scan_film is deliberately not re-derived from the film here: the loaded
     value may be an intentional override of the stock's natural mode and has to
     survive the load. Only _film_changed() re-baselines it, and only when the
     film itself changes. */

  const char *target = fe ? fe->target_print : NULL;
  int ppos = SF_COMBO_PAPER_AUTO, pfirst = -1;
  pos = 0;
  for(const GList *l = g->entries; l; l = l->next, pos++)
  {
    const sf_prof_entry_t *e = l->data;
    if(!e->printing) continue;
    if(pfirst < 0) pfirst = pos;
    if(p->paper_hash ? (e->hash == p->paper_hash) : (target && !strcmp(e->stock, target)))
      ppos = pos;
  }
  /* an edit that never picked a paper shows "auto", not the stock it happens to
     resolve to -- otherwise the link looks broken the moment it is displayed.
     A picked paper the pack no longer carries shows the paper the pipeline
     falls back to, which _resolve_stock() takes in the same order as
     _auto_paper_entry(). */
  if(!p->paper_hash) ppos = SF_COMBO_PAPER_AUTO;
  else if(ppos < 0)
  {
    const sf_prof_entry_t *fb = _auto_paper_entry(g, fe);
    ppos = fb ? g_list_index(g->entries, fb) : pfirst;
  }
  /* scanning outranks any stored paper: it is the state the pipeline is in */
  if(p->scan_film) ppos = SF_COMBO_SCAN_FILM;
  dt_bauhaus_combobox_set_from_value(g->paper, ppos);
  /* after the repopulation above, which reset the auto entry to its plain
     label: the entry names the paper this film resolves to, so a module that
     opens on auto shows the paper it is really printing on */
  _update_paper_auto_entry(self);

  {
    const int fpreset = _format_mm_to_preset(p->film_format_mm);
    dt_bauhaus_combobox_set_from_value(g->film_format_combo, fpreset);
    gtk_widget_set_visible(g->film_format_mm_slider,
                           fpreset < 0 || fpreset >= FORMAT_PRESETS_N);
  }

  /* toggle_from_params check buttons are NOT auto-synced by
     dt_bauhaus_update_from_field (it only handles sliders/combos), so set
     them here or they drift from the params: a stale box makes the first
     click a no-op (field already has that value -> no history item) and
     module reset never updates them. */
  dt_bauhaus_toggle_set(g->adaptation_bandwidth, p->adaptation_bandwidth);
  dt_bauhaus_toggle_set(g->adaptation_surface, p->adaptation_surface);
  dt_bauhaus_toggle_set(g->print_auto_exposure, p->print_auto_exposure);
  dt_bauhaus_toggle_set(g->halation_on, p->halation_on);
  dt_bauhaus_toggle_set(g->diffusion_on, p->diffusion_on);
  dt_bauhaus_toggle_set(g->print_diffusion_on, p->print_diffusion_on);
  dt_bauhaus_toggle_set(g->grain_on, p->grain_on);

  _toggle_sensitivity(g, p);
  _update_print_sensitivity(self);

  _update_trouble_message(self);
}

/* Boost that puts the probe lightness of `rgb` at `target_L`.
 *
 * Solved numerically, because L does not scale as boost^(1/3). That identity
 * would hold only if the scan stage were a pure scale on XYZ; sf_sim_scan
 * applies the scanner black/white-point correction after the boost, and that
 * correction is affine and clipped -- the delivered luminance is
 * clamp(m * boost * Y + q, 0, 1), so L goes as boost^a with a < 1/3. A
 * closed-form update from one probe converges on the answer over repeated
 * picks without reaching it. (scan_bw_on is set for a scan of positive film
 * and for a print on negative paper with the print fit on.)
 *
 * boost -> L is monotone non-decreasing and one probe is a single pixel through
 * the sim, so geometric bisection over the slider's range is exact and cheap:
 * 20 steps pin the answer to ~2e-6 of the range. The result does not depend on
 * the current slider value, so picking twice gives the same number. */
static float _solve_boost_for_lightness(const sf_sim_t *sim,
                                        const float rgb[3],
                                        const float target_L)
{
  float lo = 0.5f, hi = 4.0f; /* the slider's own $MIN / $MAX */
  if(sf_sim_probe_lightness(sim, rgb, lo) >= target_L) return lo;
  if(sf_sim_probe_lightness(sim, rgb, hi) <= target_L) return hi;
  for(int i = 0; i < 20; i++)
  {
    const float mid = sqrtf(lo * hi);
    if(sf_sim_probe_lightness(sim, rgb, mid) < target_L) lo = mid;
    else hi = mid;
  }
  return sqrtf(lo * hi);
}

/* The post-compression output scale, solved in its own slot.
 *
 * out_scale multiplies the finished RGB uniformly and RGB->XYZ is linear, so
 * L(scale) = scale^(1/3) * L(1) exactly -- one probe at 1.0 gives the answer in
 * closed form. Probing at 1.0 rather than at the current value is what makes
 * the result independent of where the slider already sits, so picking the same
 * area twice returns the same number.
 *
 * The compressor stays enabled and the boost keeps its real value here, unlike
 * the boost probe: this factor is applied after both, so a measurement taken
 * without them describes a different transfer. */
static float _solve_scale_for_lightness(const sf_sim_t *sim,
                                        const float rgb[3],
                                        const float target_L)
{
  const float L1 = sf_sim_probe_lightness_scale(sim, rgb, 1.0f);
  if(!(L1 > 1e-6f)) return 4.0f; /* black sample: nothing to scale toward the target */
  const float ratio = target_L / L1;
  /* fminf/fmaxf rather than a clamp macro: a non-finite ratio settles on the
     minimum here instead of passing through. */
  return fminf(fmaxf(ratio * ratio * ratio, 0.5f), 4.0f); /* the slider's own $MIN / $MAX */
}

void color_picker_apply(dt_iop_module_t *self,
                        GtkWidget *picker,
                        dt_dev_pixelpipe_t *pipe)
{
  dt_iop_spektrafilm_gui_data_t *g = self->gui_data;
  const gboolean is_scale = (picker == g->output_scale);
  if(picker != g->output_boost && !is_scale) return;

  /* picked_color_min/max start at sentinel values (+FLT_MAX / -FLT_MAX)
     until a real area pick has actually landed; if this callback fires
     before that (e.g. some other programmatic trigger of the picker
     path), max stays below min and using it directly would feed garbage
     (-FLT_MAX for every channel) into the simulation -- that propagates
     into a wildly out-of-range LUT lookup and segfaults. Standard
     darktable idiom for this check, matching e.g. exposure.c's own
     color_picker_apply. */
  if(self->picked_color_max[0] < self->picked_color_min[0]) return;

  const dt_iop_order_iccprofile_info_t *work_profile = dt_ioppr_get_pipe_work_profile_info(pipe);
  if(!work_profile) return;

  /* Build a standalone sim from the module's current live params: the
     picker runs independently of any specific piece's cached data, so this
     is a one-off build for this measurement, not the pipe's own d->sim
     (which _ensure_sim also caches on -- see there for what gets set). */
  dt_iop_spektrafilm_data_t d_tmp;
  memset(&d_tmp, 0, sizeof(d_tmp));
  d_tmp.p = *(dt_iop_spektrafilm_params_t *)self->params;
  dt_pthread_mutex_init(&d_tmp.lock);
  sf_sim_t *sim = _ensure_sim(&d_tmp, work_profile);
  if(!sim)
  {
    dt_pthread_mutex_destroy(&d_tmp.lock);
    return;
  }

  /* the brightest tone in the picked area is what determines whether the
     compressor's knee engages usefully; picked_color_max is already an
     area-mode min/max/mean pick (see dt_color_picker_new(..., DT_COLOR_PICKER_AREA, ...)
     above in gui_init). */
  const float rgb_max[3] = { self->picked_color_max[0], self->picked_color_max[1],
                            self->picked_color_max[2] };
  /* Where the picked highlight lands, in the probe's lightness. Above the
     compressor's knee threshold (SF_OUT_LIGHT_T = 0.7) and short of its
     asymptotic limit of 1.0: the knee only starts doing useful work well above
     its threshold, so aiming just past it leaves visible unused headroom and
     reads dark; 0.95 still does. 0.97 is close enough to the limit to use
     nearly all of the range while still landing on the curve rather than at
     its asymptote, where the solver's bisection would have little gradient to
     work with. Nothing hard-clips at any target -- the knee is asymptotic by
     construction. */
  /* The scale aims higher than the boost. The boost hands its result to the
     compressor, whose knee bends anything approaching 1.0 back down, so a
     target near the limit still lands short of it. The scale multiplies the
     finished colour with nothing behind it, so what it aims for is what it
     gets, and a highlight can sit closer to white before anything is lost. */
  const float target_L = is_scale ? 0.995f : 0.97f;
  const float solved = is_scale ? _solve_scale_for_lightness(sim, rgb_max, target_L)
                                : _solve_boost_for_lightness(sim, rgb_max, target_L);

  if(d_tmp.gpu) sf_sim_gpu_free(d_tmp.gpu);
  if(d_tmp.sim) sf_sim_free(d_tmp.sim);
  dt_pthread_mutex_destroy(&d_tmp.lock);

  dt_iop_spektrafilm_params_t *p = self->params;
  GtkWidget *w = is_scale ? g->output_scale : g->output_boost;
  if(is_scale)
    p->output_scale = solved;
  else
    p->output_luminance_boost = solved;
  DT_ENTER_GUI_UPDATE();
  dt_bauhaus_slider_set(w, solved);
  DT_LEAVE_GUI_UPDATE();
  dt_dev_add_history_item(darktable.develop, self, TRUE);
}

/* Section heading with its own reset button.

   Each tab holds several unrelated groups and darktable resets whole modules or
   single widgets, nothing in between -- so trying one idea in "chemistry" means
   either undoing every slider by hand or throwing away the rest of the tab. The
   button resets exactly the widgets its own section holds.

   Each section owns a container and every widget in it belongs to that section,
   so the button carries the container and resets its children. */
static void _section_reset_clicked(GtkButton *button,
                                   dt_iop_module_t *self)
{
  if(darktable.gui->reset) return;
  GtkWidget *box = g_object_get_data(G_OBJECT(button), "sf_section_container");
  if(!box) return;

  /* Deliberately NOT wrapped in darktable.gui->reset: each widget's own
     value-changed handler is what writes the param, so suppressing it would
     move the sliders without changing the render. The cost is one history entry
     per widget rather than one per click, which is chattier than ideal but
     correct. */
  GList *kids = gtk_container_get_children(GTK_CONTAINER(box));

  /* Undo, however, needs the whole click to be one step, and left to itself it
     is not even a complete one. Every history entry the loop below produces
     carries the widget that caused it as its undo target, and
     _dev_undo_start_record_target() in develop/develop.c drops a change
     entirely -- no undo record at all -- when its target matches the previous
     one and the two fall inside darkroom/undo/merge_same_secs. That rule exists
     to keep a slider drag from filling the undo stack, and it cannot tell a
     drag from this button writing to the same slider: resetting a widget the
     user has just moved by hand reads as a continuation of that move, so the
     value it held is gone and no amount of undo brings it back.

     dt_dev_undo_start_record() clears the stored target, which closes that hole
     for the first widget in the loop, and the level counter behind the history
     signals (record_history_level in libs/history.c) folds the records raised
     inside the loop into this outer one, so the section reverts on a single
     undo. */
  dt_dev_undo_start_record(darktable.develop);

  /* Toggles go last, as dt_ui_notebook_page()'s own double-click page reset
     does (_reset_all_bauhaus() in gui/gtk.c): a module may switch one of its
     checkboxes in reaction to one of its sliders, so a checkbox reset while
     sliders are still to come could be undone by a slider reset after it. This
     button is the page reset narrowed to a range of widgets, so it should not
     behave differently within that range. */
  for(int toggles_pass = 0; toggles_pass < 2; toggles_pass++)
  {
    for(const GList *l = kids; l; l = l->next)
    {
      GtkWidget *w = l->data;
      if(g_object_get_data(G_OBJECT(w), "sf_section_exempt")) continue;
      if(!DT_IS_BAUHAUS_WIDGET(w)) continue;
      if((dt_bauhaus_widget_get_type(w) == DT_BAUHAUS_TOGGLE) != (toggles_pass == 1)) continue;
      dt_bauhaus_widget_reset(w);
    }
  }

  dt_dev_undo_end_record(darktable.develop);
  g_list_free(kids);
}

/* Mark a widget as belonging to no section, so no section reset button touches
   it however the page is later reordered. For the master switches: an effect's
   on/off state is the tab's, not a subsection's, and sweeping it up would mean
   a click meant to restore one group's defaults silently switched the whole
   effect off -- most visibly on the diffusion stages, which default to off.
   Every bauhaus widget still resets on its own double-click, so nothing is
   lost. Returns its argument, to wrap the constructor at the point of use. */
static GtkWidget *_section_exempt(GtkWidget *w)
{
  g_object_set_data(G_OBJECT(w), "sf_section_exempt", GINT_TO_POINTER(1));
  return w;
}

/* Open a collapsible section on the current page and point self->widget at its
   container, so every widget built afterwards lands inside it until the next
   call. The reset button sits in the section header and carries the container,
   so it resets exactly the widgets the section holds.

   `confname` persists the expanded state and must be unique across the module:
   several tabs use the same section label. Passing NULL builds a fixed section
   instead -- same container and same reset button, but always open and with no
   disclosure arrow -- for a section that should not be hidden behind a click.

   Both forms put their widgets in a container of their own, so the reset button
   works identically for either. Returns the section's root widget: the
   expander, or the wrapper holding the fixed form's heading and container. */
static GtkWidget *_section_add(dt_iop_module_t *self,
                               const char *label,
                               const char *confname)
{
  dt_iop_spektrafilm_gui_data_t *g = (dt_iop_spektrafilm_gui_data_t *)self->gui_data;

  /* self->widget is the page only on the first section of a page; afterwards it
     is the previous section's container. */
  if(self->widget != g->section_container) g->section_page = self->widget;
  GtkWidget *page = g->section_page;

  if(!confname)
  {
    /* header and container live in a wrapper, so the section is a single widget
       from the outside and can be hidden in one call */
    GtkWidget *hdr = dt_gui_hbox(dt_gui_expand(dt_ui_section_label_new(label)));

    GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_BAUHAUS_SPACE);

    GtkWidget *rst = dtgtk_button_new(dtgtk_cairo_paint_reset, 0, NULL);
    gtk_widget_set_tooltip_text(rst, _("reset only this section"));
    g_object_set_data(G_OBJECT(rst), "sf_section_container", box);
    /* No expand on the button: the label beside it already takes the spare
       width, so a plain add leaves the button at the right-hand end. Expanding
       it as well makes the box split the spare width between the two, which
       moves the button in from the edge. */
    dt_gui_box_add(hdr, rst);
    g_signal_connect(G_OBJECT(rst), "clicked", G_CALLBACK(_section_reset_clicked), self);

    GtkWidget *wrap = dt_gui_vbox(hdr, box);
    dt_gui_box_add(page, wrap);

    g->section_container = box;
    self->widget = box;
    return wrap;
  }

  dt_gui_collapsible_section_t *cs = g_malloc0(sizeof(*cs));
  dt_gui_new_collapsible_section(cs, confname, label, GTK_BOX(page), DT_ACTION(self));

  /* dt_gui_new_collapsible_section appends at the end of the box, which suits a
     module carrying a single trailing section but reverses the order of
     several. Move it to the front so sections appear in the order they are
     declared. */
  g_object_ref(cs->expander);
  gtk_container_remove(GTK_CONTAINER(page), cs->expander);
  dt_gui_box_add(page, cs->expander);
  g_object_unref(cs->expander);

  GtkWidget *hdr = dtgtk_expander_get_header(DTGTK_EXPANDER(cs->expander));
  GtkWidget *btn = dtgtk_button_new(dtgtk_cairo_paint_reset, 0, NULL);
  gtk_widget_set_tooltip_text(btn, _("reset only this section"));
  g_object_set_data(G_OBJECT(btn), "sf_section_container", cs->container);
  /* Same as above, and here the arrow is the thing that moves:
     dt_gui_new_collapsible_section() packs its label to expand and its toggle
     not to, so an expanding button added after them takes half the spare width
     and pushes the arrow off the edge. */
  dt_gui_box_add(hdr, btn);
  g_signal_connect(G_OBJECT(btn), "clicked", G_CALLBACK(_section_reset_clicked), self);

  g->sections = g_list_prepend(g->sections, cs);
  g->section_container = GTK_WIDGET(cs->container);
  self->widget = GTK_WIDGET(cs->container);
  return cs->expander;
}

void gui_init(dt_iop_module_t *self)
{
  dt_iop_spektrafilm_gui_data_t *g = IOP_GUI_ALLOC(spektrafilm);
  self->widget = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

  GtkWidget *sf_main_box = self->widget;

  /* ---- data pack row (packed first, so it reads as a precondition) ----
     Deliberately outside main_box: it is the one thing that must stay on
     screen when there is no pack, since it is what gets you one.

     Built with no_show_all set, because dt_iop_gui_init() runs a single
     gtk_widget_show_all() over the whole module at creation time. Without the
     flag that one call would reveal the row before _update_data_row() has had
     any chance to decide whether it should be there. */
  /* Children of these vertical boxes are added plain: a fixed-height bauhaus
     widget has no use for vertical growth, and across the column it already
     fills. dt_gui_expand() would ask for horizontal growth, which is not what
     a column wants. */
  g->data_gen = sf_fetch_generation();
  g->data_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, DT_PIXEL_APPLY_DPI(2));
  gtk_widget_set_no_show_all(g->data_box, TRUE);

  g->data_status = gtk_label_new("");
  gtk_label_set_line_wrap(GTK_LABEL(g->data_status), TRUE);
  gtk_label_set_xalign(GTK_LABEL(g->data_status), 0.0);
  gtk_widget_set_name(g->data_status, "spektrafilm-data-status");
  dt_gui_box_add(g->data_box, g->data_status);

  g->data_button = gtk_button_new_with_label(_("download data pack"));
  g_signal_connect(G_OBJECT(g->data_button), "clicked",
                   G_CALLBACK(_data_button_clicked), self);
  dt_gui_box_add(g->data_box, g->data_button);

  gtk_widget_show(g->data_status);
  gtk_widget_show(g->data_button);
  dt_gui_box_add(sf_main_box, g->data_box);

  /* Everything else lives under main_box so a single set_visible() hides the
     lot. No no_show_all here: the creation-time show_all is what establishes
     the correct visibility of every child, including the ones with their own
     rules (the format slider is only shown for a custom format), and redoing
     that by hand on reveal would quietly override them. Since that show_all
     runs once at creation and gui_update() runs after it, hiding here sticks,
     and revealing later restores exactly the state show_all left behind. */
  g->main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
  dt_gui_box_add(sf_main_box, g->main_box);

  /* ---- header ---- */
  GtkWidget *header_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
  dt_gui_box_add(g->main_box, header_box);

  /* Inline labels, like every other control in the module. _shorten_name()
     strips the filler words the paper names share, so they diverge at the
     seventh character ("Kodak Endura Premier" / "Kodak Portra Endura" /
     "Kodak Supra Endura" / "Kodak Ultra Endura") and fit beside a label. */
  g->film = dt_bauhaus_combobox_new(self);
  dt_bauhaus_widget_set_label(g->film, NULL, N_("film stock"));
  gtk_widget_set_tooltip_text(g->film, _("film emulsion (spektrafilm filming profile)"));
  g_signal_connect(G_OBJECT(g->film), "value-changed", G_CALLBACK(_film_changed), self);
  dt_gui_box_add(header_box, g->film);

  g->paper = dt_bauhaus_combobox_new(self);
  gtk_widget_set_tooltip_text(
      g->paper, _("print paper. \"none\" views the developed film directly, with no\n"
                  "print stage, which is the natural mode for positive and reversal\n"
                  "stocks; \"auto\" uses the paper named in the film stock's profile."));
  dt_bauhaus_widget_set_label(g->paper, NULL, N_("print paper"));
  g_signal_connect(G_OBJECT(g->paper), "value-changed", G_CALLBACK(_paper_changed), self);
  dt_gui_box_add(header_box, g->paper);

  /* redirect self->widget so from_params widgets pack into header_box */
  self->widget = header_box;

  g->film_format_combo = dt_bauhaus_combobox_new(self);
  dt_bauhaus_widget_set_label(g->film_format_combo, NULL, N_("format"));
  gtk_widget_set_tooltip_text(g->film_format_combo,
                              _("common film/sensor gate presets; picking one sets the "
                                "frame long\n"
                                "edge slider below (pick \"custom\" to dial in an exact "
                                "value). the\n"
                                "preset names a film gauge (35mm) while the slider is the "
                                "frame's\n"
                                "long edge (36mm). both describe the same format."));
  _populate_format_combo(self);
  g_signal_connect(G_OBJECT(g->film_format_combo), "value-changed",
                   G_CALLBACK(_format_changed), self);
  dt_gui_box_add(header_box, g->film_format_combo);

  g->film_format_mm_slider = dt_bauhaus_slider_from_params(self, "film_format_mm");
  dt_bauhaus_slider_set_format(g->film_format_mm_slider, _(" mm"));
  /* the hard range spans every format preset, Super 8 to 8x10; dragging
     covers the common still and cine gauges */
  dt_bauhaus_slider_set_soft_range(g->film_format_mm_slider, 8.0f, 130.0f);
  gtk_widget_set_tooltip_text(g->film_format_mm_slider,
                              _("physical frame size, long edge. sets the scale that "
                                "grain, scatter,\n"
                                "halation, diffusion and the coupler spread are all "
                                "computed at, so\n"
                                "a smaller format shows every one of them proportionally "
                                "larger for\n"
                                "the same print size."));

  /* restore main widget for the notebook */
  self->widget = sf_main_box;

  /* ---- notebook / tabs ---- */
  static struct dt_action_def_t notebook_def = { };
  g->notebook = dt_ui_notebook_new(&notebook_def);
  dt_action_define_iop(self, NULL, N_("page"), GTK_WIDGET(g->notebook), &notebook_def);
  dt_gui_box_add(g->main_box, GTK_WIDGET(g->notebook));

  /* ---- tab 1: film (exposure + development) ---- */
  self->widget = dt_ui_notebook_page(g->notebook, N_("film"), NULL);

  _section_add(self, C_("section", "exposure"), NULL);

  g->exposure_ev = dt_bauhaus_slider_from_params(self, "exposure_ev");
  /* Dragging covers +-4 EV, the range this is used over. The hard range reaches
     +-12 for a typed value: an evening frame whose highlights were held by a
     large positive exposure upstream needs a comparable negative here to put
     the scene back on the film's curve, and +-4 cannot undo +8. */
  dt_bauhaus_slider_set_soft_range(g->exposure_ev, -4.0f, 4.0f);
  dt_bauhaus_slider_set_format(g->exposure_ev, _(" EV"));
  gtk_widget_set_tooltip_text(
      g->exposure_ev, _("film exposure compensation; with auto print exposure enabled, "
                        "print\n"
                        "exposure follows automatically so this has no net brightness "
                        "effect\n"
                        "(except when the film is scanned directly, with no print "
                        "stage)."));
  g->push_pull_stops = dt_bauhaus_slider_from_params(self, "push_pull_stops");
  dt_bauhaus_slider_set_format(g->push_pull_stops, _(" stops"));
  gtk_widget_set_tooltip_text(
      g->push_pull_stops,
      _("push (positive) or pull (negative) processing: shoot at an effective\n"
        "ISO different from box speed, then under- or over-develop to\n"
        "compensate. combines an exposure shift with a derived contrast\n"
        "increase/decrease (approximate: the exact relationship depends on\n"
        "the specific film/developer combination, which isn't modeled here).\n"
        "stacks with the granular gamma controls below for\n"
        "further fine-tuning."));

  _section_add(self, C_("section", "chemistry"), "plugins/darkroom/spektrafilm/expand_film_chemistry");

  g->development_min = dt_bauhaus_slider_from_params(self, "development_min");
  dt_bauhaus_slider_set_format(g->development_min, _(" min"));
  /* gui_update() replaces this with the selected stock's own times, and greys
     the slider out for stocks characterised at a single development */
  gtk_widget_set_tooltip_text(g->development_min,
                              _("development time. snaps to the nearest time the stock was\n"
                                "characterized at; 0 uses the stock's own default."));

  g->film_gamma_factor = dt_bauhaus_slider_from_params(self, "film_gamma_factor");
  dt_bauhaus_slider_set_soft_range(g->film_gamma_factor, 0.25f, 2.0f);
  gtk_widget_set_tooltip_text(
      g->film_gamma_factor,
      _("overall development contrast (morphs the film's density curves).\n"
        "extended or reduced development time, as in push/pull processing;\n"
        "1.0 = normal development."));

  g->film_gamma_factor_fast = dt_bauhaus_slider_from_params(self, "film_gamma_factor_fast");
  dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_fast, 0.25f, 2.0f);
  gtk_widget_set_tooltip_text(
      g->film_gamma_factor_fast,
      _("contrast of the fastest (most light-sensitive) emulsion sub-layer\n"
        "only, independent of the slow layer, since push/pull processing\n"
        "doesn't always affect every sub-layer equally."));

  g->film_gamma_factor_slow = dt_bauhaus_slider_from_params(self, "film_gamma_factor_slow");
  dt_bauhaus_slider_set_soft_range(g->film_gamma_factor_slow, 0.25f, 2.0f);
  gtk_widget_set_tooltip_text(
      g->film_gamma_factor_slow,
      _("contrast of the mid and slow emulsion sub-layers"));

  g->film_developer_exhaustion = dt_bauhaus_slider_from_params(self, "film_developer_exhaustion");
  gtk_widget_set_tooltip_text(
      g->film_developer_exhaustion,
      _("developer depletion in dense (highly-exposed) areas, modelled as a\n"
        "change to the density curve rather than a spatial effect: blends\n"
        "the highlight shoulder toward a self-limiting rolloff without\n"
        "shifting midgray (0 = off)."));

  /* One mechanism, six controls, and the two spread values read as sharpening
     rather than as colour -- enough of a group, and enough of a surprise, to
     want a heading naming it. */
  _section_add(self, C_("section", "DIR couplers"), "plugins/darkroom/spektrafilm/expand_film_dir_couplers");

  g->couplers_amount = dt_bauhaus_slider_from_params(self, "couplers_amount");
  gtk_widget_set_tooltip_text(g->couplers_amount,
                              _("overall coupler strength: inter-layer inhibition drives "
                                "saturation\n"
                                "and edge effects (1.0 = film-accurate, 0 = off)."));

  g->couplers_inhibition_same = dt_bauhaus_slider_from_params(self, "couplers_inhibition_same");
  gtk_widget_set_tooltip_text(g->couplers_inhibition_same,
                              _("how strongly a layer inhibits its own development. "
                                "raising it\n"
                                "flattens contrast within each channel."));

  g->couplers_inhibition_inter = dt_bauhaus_slider_from_params(self, "couplers_inhibition_inter");
  gtk_widget_set_tooltip_text(g->couplers_inhibition_inter,
                              _("how strongly each layer inhibits the other two. this is "
                                "the part\n"
                                "that produces the saturation couplers are known for; at 0 "
                                "the\n"
                                "channels develop independently."));

  g->couplers_diffusion_um = dt_bauhaus_slider_from_params(self, "couplers_diffusion_um");
  dt_bauhaus_slider_set_format(g->couplers_diffusion_um, _(" \302\265m"));
  gtk_widget_set_tooltip_text(
      g->couplers_diffusion_um,
      _("how far the released inhibitor spreads through the emulsion.\n"
        "\n"
        "this is what turns the couplers into an edge effect: a wide spread\n"
        "lets a bright area hold back development well beyond its own edge,\n"
        "which reads as added clarity or sharpening. shorten it for a softer,\n"
        "more purely tonal coupler response.\n"
        "\n"
        "resetting returns it to the loaded film stock's own value."));

  g->couplers_tail_um = dt_bauhaus_slider_from_params(self, "couplers_tail_um");
  dt_bauhaus_slider_set_format(g->couplers_tail_um, _(" \302\265m"));
  gtk_widget_set_tooltip_text(
      g->couplers_tail_um,
      _("length of the long, faint tail on the spread above, which carries\n"
        "the inhibitor much further at low weight.\n"
        "\n"
        "it is what gives large bright areas a broad, gentle falloff. set the\n"
        "weight to 0 to drop it entirely."));

  g->couplers_tail_weight = dt_bauhaus_slider_from_params(self, "couplers_tail_weight");
  gtk_widget_set_tooltip_text(g->couplers_tail_weight,
                              _("the share of the inhibitor that travels in the long tail. 0 "
                                "removes the tail."));

  /* The diffusion filter sits in front of the taking lens, so it belongs with
     the rest of the film stage rather than on a tab of its own. */
  _section_add(self, C_("section", "diffusion"),
               "plugins/darkroom/spektrafilm/expand_film_diffusion");

  /* Inside the section, not ahead of it: widgets built before _section_add()
     go into whichever container is still open, so on a page of collapsible
     sections a master toggle placed first lands in the previous section.
     Exempt, so resetting this section leaves the switch alone. */
  g->diffusion_on = _section_exempt(dt_bauhaus_toggle_from_params(self, "diffusion_on"));

  g->diffusion_filter_family = dt_bauhaus_combobox_from_params(self, "diffusion_filter_family");
  gtk_widget_set_tooltip_text(
      g->diffusion_filter_family,
      _("diffusion filter type: black pro-mist (concentrated, punchy halo,\n"
        "deep blacks) / glimmerglass (tight, subtle, sharp-preserving) /\n"
        "pro-mist (broader, pastel, atmospheric) / cinebloom (frame-wide,\n"
        "slow-decaying veil)."));

  g->diffusion_strength = dt_bauhaus_slider_from_params(self, "diffusion_strength");
  /* Diffusion filters are sold in stops, and the deflected-light table this
     drives is calibrated at those stops -- 1/8, 1/4, 1/2, 1 and 2 -- with log2
     interpolation between them. The ste