OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / RenderService.cs

684 lines · csharp ref:
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

using IFSEngine.Model;
using IFSEngine.Rendering;
using IFSEngine.Utility;

using IFSRenderer.AvaloniaShell.Rendering;

using IFSRenderer.Shared;

using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;

namespace IFSRenderer.AvaloniaShell;

/// <summary>
/// Owns the OpenGL context and the <see cref="RendererGL"/> on a single dedicated
/// thread, and publishes finished frames as a BGRA byte buffer for the UI.
/// </summary>
/// <remarks>
/// <para>
/// An OpenGL context is current on exactly ONE thread, so every engine call must
/// happen on the thread that created it. All UI interaction therefore goes through
/// <see cref="Post"/> / <see cref="PostAsync"/>, which queue work onto that thread.
/// </para>
/// <para>
/// The engine is async internally. We install a <see cref="QueueSynchronizationContext"/>
/// so any continuation is pumped back onto the GL thread rather than escaping to the
/// ThreadPool - where a GL call would segfault inside libGLdispatch. This is the role
/// WPF's Dispatcher played in the original app.
/// </para>
/// <para>
/// Frames reach the UI as a CPU copy (<see cref="RendererGL.CopyPixelDataToBitmap"/>,
/// the engine's own bitmap path, which emits BGRA8 - exactly Avalonia's Bgra8888).
/// This deliberately avoids sharing the GL context with Avalonia's compositor, whose
/// context is not guaranteed to expose the GL 4.3+ compute shaders the engine needs.
/// </para>
/// </remarks>
public sealed class RenderService : IRenderQueue
{
    private readonly BlockingCollection<Action> _queue = new();
    private readonly ManualResetEventSlim _started = new(false);
    private Thread _thread;
    private NativeWindow _window;
    private RendererGL _renderer;

    /// <summary>The narrow view of <see cref="_renderer"/> handed to queued UI actions.</summary>
    private IRenderer _api;

    private volatile bool _running;
    private volatile bool _paused;

    /// <summary>Guards <see cref="FrameBuffer"/> against the UI thread reading a torn frame.</summary>
    public object FrameLock { get; } = new();

    /// <summary>Bottom-up BGRA8 pixels, <see cref="FrameWidth"/> x <see cref="FrameHeight"/>.</summary>
    public byte[] FrameBuffer { get; private set; } = [];
    public int FrameWidth { get; private set; }
    public int FrameHeight { get; private set; }

    public event Action FrameReady;
    public event Action<Exception> Failed;

    public string GpuName { get; private set; } = "(not initialised)";
    public Exception StartupError { get; private set; }
    public IReadOnlyList<TransformPlugin> Transforms { get; private set; } = [];
    public IReadOnlyList<EffectPlugin> Effects { get; private set; } = [];
    public IReadOnlyList<string> IncludeSources { get; private set; } = [];
    public IFS Current { get; private set; }

    public ulong TotalIterations => _renderer?.TotalIterations ?? 0;
    public int IterationLevel => _renderer is null || _renderer.HistogramWidth == 0
        ? 0
        : System.Numerics.BitOperations.Log2(1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
    public double Fps { get; private set; }

    /// <summary>Iterations per second, measured over the last sampling window.</summary>
    public double IterationSpeed { get; private set; }

    /// <summary>Iterations each GPU invocation performs; the engine tunes this to hit TargetFramerate.</summary>
    public int InvocationIters => _renderer?.InvocationIters ?? 0;

    public int WorkgroupCount => _renderer?.WorkgroupCount ?? 0;

    /// <summary>Pauses compute dispatching (e.g. while a modal export runs).</summary>
    /// <remarks>Resuming clears <see cref="LastError"/>: the user is retrying.</remarks>
    public bool Paused
    {
        get => _paused;
        set
        {
            if (!value)
                LastError = null;
            _paused = value;
        }
    }

    /// <summary>
    /// The exception that stopped the render loop, or null while it is healthy.
    /// </summary>
    /// <remarks>
    /// A throw from <see cref="RendererGL.DispatchCompute"/> pauses the loop rather than
    /// spinning on a failing GL call. That pause used to be invisible — <see cref="Failed"/>
    /// wrote to the status bar, which the twice-a-second status ticker overwrote immediately,
    /// so the renderer simply appeared to stop for no reason. Keeping the error here lets the
    /// UI keep showing it until rendering is actually resumed.
    /// </remarks>
    public Exception LastError { get; private set; }

    // ---------------------------------------------------------------- lifecycle

    /// <summary>
    /// Loads the plugin library off-thread, then starts the GL thread.
    /// File I/O is done here, BEFORE the GL context exists, so its async
    /// continuations cannot land on the GL thread.
    /// </summary>
    public async Task StartAsync(string libraryDir, string transformsDir)
    {
        string includesDir = Path.Combine(libraryDir, "Includes");
        string postFxDir = Path.Combine(libraryDir, "PostEffects");

        var includeFiles = Directory.Exists(includesDir) ? Directory.GetFiles(includesDir) : [];
        IncludeSources = (await Task.WhenAll(includeFiles.Select(f => File.ReadAllTextAsync(f)))).ToList();
        var includeNames = includeFiles.Select(Path.GetFileName).ToList();

        var allTransforms = (await Task.WhenAll(Directory
            .GetFiles(transformsDir, "*.ifstf", SearchOption.AllDirectories)
            .Select(TransformPlugin.FromFile))).ToList();
        Transforms = allTransforms.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();

        var allEffects = Directory.Exists(postFxDir)
            ? (await Task.WhenAll(Directory.GetFiles(postFxDir, "*.ifsfx", SearchOption.AllDirectories)
                .Select(EffectPlugin.FromFile))).ToList()
            : [];
        Effects = allEffects.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();

        _running = true;
        _thread = new Thread(ThreadMain)
        {
            Name = "IFSEngine GL",
            IsBackground = true,
        };
        _thread.Start();

        await Task.Run(() => _started.Wait());
        if (StartupError is not null)
            throw new InvalidOperationException("Renderer failed to start.", StartupError);
    }

    private void ThreadMain()
    {
        try
        {
            SynchronizationContext.SetSynchronizationContext(new QueueSynchronizationContext(_queue));

            // The window was created on the main thread (GLFW requires that);
            // acquiring its context here is legal and is what pins the engine
            // to this thread for the rest of its life.
            _window = Program.GlWindow;
            _window.Context.MakeCurrent();
            GpuName = OpenTK.Graphics.OpenGL.GL.GetString(OpenTK.Graphics.OpenGL.StringName.Renderer);

            _renderer = new RendererGL(_window.Context);
            _api = new RendererGLAdapter(_renderer);
            PumpUntilComplete(_renderer.Initialize(IncludeSources, Transforms, Effects));
        }
        catch (Exception ex)
        {
            StartupError = ex;
            _started.Set();
            return;
        }
        _started.Set();

        var frameTimer = System.Diagnostics.Stopwatch.StartNew();
        var fpsTimer = System.Diagnostics.Stopwatch.StartNew();
        int framesSincePresent = 0;
        ulong lastIterations = 0;

        while (_running)
        {
            DrainQueue();
            if (!_running)
                break;

            if (!_paused && Current is not null)
            {
                try
                {
                    _renderer.DispatchCompute();
                }
                catch (Exception ex)
                {
                    LastError = ex;
                    _paused = true;
                    Failed?.Invoke(ex);
                }
            }
            else
            {
                Thread.Sleep(5);
            }

            // ~30 fps presentation; the compute loop itself runs as fast as it can.
            if (frameTimer.ElapsedMilliseconds >= 33 && Current is not null && !_paused)
            {
                Present();
                framesSincePresent++;
                frameTimer.Restart();
            }

            if (fpsTimer.ElapsedMilliseconds >= 1000)
            {
                double seconds = fpsTimer.ElapsedMilliseconds / 1000.0;
                Fps = framesSincePresent / seconds;
                ulong now = _renderer.TotalIterations;
                // TotalIterations resets whenever the histogram is invalidated, so a
                // negative delta means "reset", not "went backwards".
                IterationSpeed = now >= lastIterations ? (now - lastIterations) / seconds : 0;
                lastIterations = now;
                framesSincePresent = 0;
                fpsTimer.Restart();
            }
        }

        try
        {
            PumpUntilComplete(_renderer.DisposeAsync().AsTask());
            // The window itself is owned by Program (main thread) - don't dispose it here.
            _window.Context.MakeNoneCurrent();
        }
        catch { /* shutting down */ }
    }

    private void Present()
    {
        _renderer.RenderImage();

        int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
        if (w <= 0 || h <= 0)
            return;

        lock (FrameLock)
        {
            int needed = w * h * 4;
            if (FrameBuffer.Length != needed)
                FrameBuffer = new byte[needed];

            GCHandle pin = GCHandle.Alloc(FrameBuffer, GCHandleType.Pinned);
            try
            {
                PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject()));
            }
            finally
            {
                pin.Free();
            }
            FrameWidth = w;
            FrameHeight = h;
        }

        FrameReady?.Invoke();
    }

    // ------------------------------------------------------------- GL-thread ops

    /// <summary>Queues an action to run on the GL thread with the engine.</summary>
    public void Post(Action<IRenderer> action)
    {
        if (!_running)
            return;
        _queue.Add(() =>
        {
            try { action(_api); }
            catch (Exception ex) { Failed?.Invoke(ex); }
        });
    }

    /// <summary>Queues an action and awaits its completion.</summary>
    public Task PostAsync(Action<IRenderer> action)
    {
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        if (!_running)
        {
            tcs.SetResult();
            return tcs.Task;
        }
        _queue.Add(() =>
        {
            try { action(_api); tcs.TrySetResult(); }
            catch (Exception ex) { tcs.TrySetException(ex); }
        });
        return tcs.Task;
    }

    /// <summary>Loads a fractal and sizes the preview histogram to the viewport.</summary>
    public void LoadFractal(IFS fractal, int viewportWidth, int viewportHeight)
    {
        Current = fractal;
        Post(r =>
        {
            r.LoadParams(fractal);
            if (viewportWidth > 0 && viewportHeight > 0)
            {
                _renderer.SetDisplayResolution(viewportWidth, viewportHeight);
                r.SetHistogramScaleToDisplay();
            }
        });
    }

    /// <summary>
    /// Sizes the preview to the viewport.
    /// </summary>
    /// <param name="fillViewport">
    /// In exploration mode the preview is rendered at exactly the viewport's pixel size, so it
    /// fills the window. The engine always gives the histogram the ASPECT of
    /// <c>ImageResolution</c> and fits it at 98% (<c>SetHistogramScaleToDisplay</c>), so a
    /// 16:9 fractal in a wider viewport is letterboxed; matching the resolution to the viewport
    /// removes both the bars and the 2% inset. Export is unaffected — ExportAsync sets the
    /// resolution it was asked for and restores it afterwards.
    /// </param>
    public void SetViewport(int width, int height, bool fillViewport = true)
    {
        if (width <= 0 || height <= 0)
            return;
        Post(r =>
        {
            _renderer.SetDisplayResolution(width, height);
            IFS f = Current;
            if (fillViewport && f is not null)
            {
                f.ImageResolution = new System.Drawing.Size(width, height);
                r.LoadParams(f);
                r.SetHistogramScale(1.0);
            }
            else
            {
                r.SetHistogramScaleToDisplay();
            }
        });
    }

    /// <summary>
    /// Accumulates samples until the histogram reaches <paramref name="targetLevel"/>.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Shared by both export paths so they agree on when a frame is finished.
    /// </para>
    /// <para>
    /// Dispatches at least once, and that is the whole point. <see cref="RendererGL"/> clears
    /// the histogram and zeroes <see cref="RendererGL.TotalIterations"/> INSIDE
    /// <see cref="RendererGL.DispatchCompute"/>, when it observes the flags raised by
    /// <see cref="RendererGL.LoadParams"/> (RendererGL.cs:420-432) - invalidating alone changes
    /// no counter. Testing the level first therefore reads the PREVIOUS frame's exhausted
    /// counter, which is by construction already at the target, so the loop is skipped, the
    /// reset never happens, and the frame is tonemapped from the stale histogram. That is what
    /// made a clip export write N identical copies of frame 0.
    /// </para>
    /// </remarks>
    internal static void AccumulateToLevel(int targetLevel, Func<int> level, Action dispatch,
        Action<int> onLevel = null)
    {
        int lastReported = -1;
        int lvl;
        do
        {
            dispatch();
            lvl = level();
            if (lvl != lastReported)
            {
                lastReported = lvl;
                onLevel?.Invoke(lvl);
            }
        }
        while (lvl < targetLevel);
    }

    /// <summary>
    /// Renders the loaded fractal at full resolution to the target iteration level
    /// and writes it out, then restores the interactive preview scale.
    /// </summary>
    /// <param name="metadata">
    /// Export options from Settings: whether to write image metadata, whether to embed the
    /// params, and the author name to attribute.
    /// </param>
    public Task ExportAsync(string path, int width, int height, int targetLevel,
        Action<int> onLevel = null, ExportMetadata metadata = null)
    {
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add(() =>
        {
            try
            {
                IFS fractal = Current;
                var previousResolution = fractal.ImageResolution;
                double previousLevel = fractal.TargetIterationLevel;

                fractal.ImageResolution = new System.Drawing.Size(width, height);
                fractal.TargetIterationLevel = targetLevel;
                _renderer.LoadParams(fractal);
                _renderer.SetHistogramScale(1.0);

                AccumulateToLevel(targetLevel, Level, _renderer.DispatchCompute, onLevel);
                _renderer.RenderImage();

                if (path.EndsWith(".exr", StringComparison.OrdinalIgnoreCase))
                {
                    float[][][] hdr = null;
                    PumpUntilComplete(_renderer.ReadHistogramData().ContinueWith(t => hdr = t.Result));
                    using var fs = File.Create(path);
                    OpenEXR.WriteStream(fs, hdr);
                }
                else
                {
                    int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
                    byte[] bgra = new byte[w * h * 4];
                    GCHandle pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
                    try { PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject())); }
                    finally { pin.Free(); }
                    ExportMetadata m = metadata ?? ExportMetadata.Default;
                    PngParams.SavePng(path, bgra, w, h, fractal,
                        m.IncludeParams, m.SaveMetadata, m.Author, m.CreatedUtc);
                }

                // restore interactive preview
                fractal.ImageResolution = previousResolution;
                fractal.TargetIterationLevel = previousLevel;
                _renderer.LoadParams(fractal);
                _renderer.SetHistogramScaleToDisplay();

                tcs.TrySetResult();
            }
            catch (Exception ex)
            {
                tcs.TrySetException(ex);
            }
        });
        return tcs.Task;

        int Level() => System.Numerics.BitOperations.Log2(
            1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
    }

    /// <summary>
    /// Renders every frame of the clip to a PNG sequence, evaluating the dopesheet at each
    /// frame time. Runs entirely on the GL thread, so the preview is naturally suspended.
    /// </summary>
    /// <param name="rawExr">
    /// Write 32-bit .exr frames instead of 8-bit .png. Settings > Animations > Frame export
    /// format.
    /// </param>
    /// <param name="frameSink">
    /// When set, receives each frame's BGRA bytes INSTEAD of a png being written - used to pipe
    /// straight into an encoder. The caller owns whatever it writes to. Ignored for
    /// <paramref name="rawExr"/>, which has no streaming form.
    /// </param>
    public Task ExportFramesAsync(string directory, int width, int height, int targetLevel,
        Action<int, int> onProgress, Func<bool> isCancelled, bool rawExr = false,
        Action<byte[], int, int> frameSink = null)
    {
        var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add(() =>
        {
            try
            {
                IFS fractal = Current;
                var previousResolution = fractal.ImageResolution;
                double previousLevel = fractal.TargetIterationLevel;

                int fps = Math.Max(fractal.Dopesheet.Fps, 1);
                int frames = Math.Max((int)Math.Round(fractal.Dopesheet.Length.TotalSeconds * fps), 1);

                fractal.ImageResolution = new System.Drawing.Size(width, height);
                fractal.TargetIterationLevel = targetLevel;
                Directory.CreateDirectory(directory);

                for (int frame = 0; frame < frames; frame++)
                {
                    if (isCancelled())
                        break;

                    double t = frame / (double)fps;
                    fractal.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)));

                    // A different seed per frame stops the noise pattern from freezing.
                    _renderer.Seed = (uint)frame;
                    _renderer.LoadParams(fractal);
                    _renderer.SetHistogramScale(1.0);

                    AccumulateToLevel(targetLevel, Level, _renderer.DispatchCompute);
                    _renderer.RenderImage();

                    if (rawExr)
                    {
                        float[][][] hdr = null;
                        PumpUntilComplete(_renderer.ReadHistogramData().ContinueWith(t => hdr = t.Result));
                        using var fs = File.Create(Path.Combine(directory, $"frame_{frame:D5}.exr"));
                        OpenEXR.WriteStream(fs, hdr);
                    }
                    else
                    {
                        int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
                        byte[] bgra = new byte[w * h * 4];
                        GCHandle pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
                        try { PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject())); }
                        finally { pin.Free(); }

                        if (frameSink is not null)
                        {
                            // Straight to the encoder; nothing touches the disk. A slow encoder
                            // blocks here, which is the backpressure we want.
                            frameSink(bgra, w, h);
                        }
                        else
                        {
                            // Frames never carry params: they would bloat a few thousand files.
                            PngParams.SavePng(Path.Combine(directory, $"frame_{frame:D5}.png"), bgra, w, h,
                                null, includeParams: false, saveMetadata: false);
                        }
                    }
                    onProgress?.Invoke(frame + 1, frames);
                }

                fractal.ImageResolution = previousResolution;
                fractal.TargetIterationLevel = previousLevel;
                _renderer.LoadParams(fractal);
                _renderer.SetHistogramScaleToDisplay();
                tcs.TrySetResult();
            }
            catch (Exception ex)
            {
                tcs.TrySetException(ex);
            }
        });
        return tcs.Task;

        int Level() => System.Numerics.BitOperations.Log2(
            1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
    }

    public Task SetWorkgroupCountAsync(int count) =>
        PostAsync(r => PumpUntilComplete(r.SetWorkgroupCount(count)));

    /// <summary>
    /// Re-reads the transform and effect plugin files from disk and recompiles the kernel,
    /// so edits to a .ifstf/.ifsfx can be seen without restarting.
    /// </summary>
    public async Task ReloadPluginsAsync(string libraryDir, string transformsDir)
    {
        // File I/O off the GL thread first, exactly as at startup.
        string includesDir = Path.Combine(libraryDir, "Includes");
        string postFxDir = Path.Combine(libraryDir, "PostEffects");

        var includeFiles = Directory.Exists(includesDir) ? Directory.GetFiles(includesDir) : [];
        var includes = (await Task.WhenAll(includeFiles.Select(f => File.ReadAllTextAsync(f)))).ToList();
        var includeNames = includeFiles.Select(Path.GetFileName).ToList();

        var allTransforms = (await Task.WhenAll(Directory
            .GetFiles(transformsDir, "*.ifstf", SearchOption.AllDirectories)
            .Select(TransformPlugin.FromFile))).ToList();
        var transforms = allTransforms.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();

        var allEffects = Directory.Exists(postFxDir)
            ? (await Task.WhenAll(Directory.GetFiles(postFxDir, "*.ifsfx", SearchOption.AllDirectories)
                .Select(EffectPlugin.FromFile))).ToList()
            : [];
        var effects = allEffects.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();

        await PostAsync(r =>
        {
            PumpUntilComplete(_renderer.LoadPlugins(includes, transforms, effects));
            IncludeSources = includes;
            Transforms = transforms;
            Effects = effects;
            // Re-bind the open fractal to the freshly parsed plugin instances.
            Current?.ReloadPlugins(transforms, effects);
            if (Current is not null)
                r.LoadParams(Current);
        });
    }

    /// <summary>
    /// Renders a small preview of an arbitrary fractal without disturbing the open one,
    /// used by the generator to show candidates.
    /// </summary>
    public Task<byte[]> RenderThumbnailAsync(IFS candidate, int width, int height, int level)
    {
        var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
        _queue.Add(() =>
        {
            try
            {
                IFS restore = Current;
                candidate.ImageResolution = new System.Drawing.Size(width, height);
                _renderer.LoadParams(candidate);
                _renderer.SetHistogramScale(1.0);

                while (Level() < level)
                    _renderer.DispatchCompute();
                _renderer.RenderImage();

                int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
                byte[] bgra = new byte[w * h * 4];
                GCHandle pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
                try { PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject())); }
                finally { pin.Free(); }

                if (restore is not null)
                {
                    _renderer.LoadParams(restore);
                    _renderer.SetHistogramScaleToDisplay();
                }
                tcs.TrySetResult(bgra);
            }
            catch (Exception ex)
            {
                tcs.TrySetException(ex);
            }
        });
        return tcs.Task;

        int Level() => System.Numerics.BitOperations.Log2(
            1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
    }

    // ---------------------------------------------------------------- internals

    private void DrainQueue()
    {
        while (_queue.TryTake(out Action action))
            action();
    }

    /// <summary>
    /// Runs the GL thread's continuation queue until <paramref name="task"/> finishes.
    /// Never blocks on the task directly - its continuations may need this very thread.
    /// </summary>
    private void PumpUntilComplete(Task task)
    {
        while (!task.IsCompleted)
        {
            if (_queue.TryTake(out Action action, 5))
                action();
        }
        task.GetAwaiter().GetResult();
    }

    private bool _disposed;

    /// <summary>Stops the render thread and releases the queue. Safe to call more than once.</summary>
    public void Dispose()
    {
        if (_disposed)
            return;
        _disposed = true;

        _running = false;
        // Wake the thread so it observes _running and unwinds, then let it finish before the
        // queue goes away — adding to a disposed BlockingCollection throws.
        try { _queue.Add(() => { }); }
        catch (ObjectDisposedException) { /* already torn down */ }
        _thread?.Join(3000);
        _queue.Dispose();
        _started.Dispose();
    }

    /// <summary>Posts continuations back onto the GL thread's work queue.</summary>
    private sealed class QueueSynchronizationContext : SynchronizationContext
    {
        private readonly BlockingCollection<Action> _target;
        public QueueSynchronizationContext(BlockingCollection<Action> target) => _target = target;
        public override void Post(SendOrPostCallback d, object state) => _target.Add(() => d(state));
        public override void Send(SendOrPostCallback d, object state) => d(state);
    }
}