Skip to main content

rlx_core/render/
mod.rs

1//! The render seam: take an [`AnalysisFrame`], drive the active preset's system,
2//! draw one frame.
3//!
4//! The render loop is driven by the frontend at display cadence and is fully
5//! decoupled from audio delivery — the ring buffer is the seam (CLAUDE.md).
6//! Cycling moves between loaded presets (ADR-0002); each preset names a built-in
7//! system and binds its parameters to expressions the renderer evaluates from
8//! the analysis frame plus the shared scene clock.
9
10// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every displayed
11// frame; a panic here is a visible crash mid-show.
12#![deny(
13    clippy::unwrap_used,
14    clippy::expect_used,
15    clippy::indexing_slicing,
16    clippy::panic,
17    clippy::unreachable
18)]
19
20// The four compositing stages stay crate-private to the outside world, but are
21// `pub(crate)` so `preset::schema` can read their global `PARAMS` vocabularies
22// for the load-time typo check (ADR-0020). `post` holds the two **per-preset**
23// stages that run after the scene, behind one trait in one fixed-order chain
24// (ADR-0031); the engine-wide passes stay outside it (ADR-0032) — `background`
25// is the pre-pass that owns the clear, `ink` the terminal tone remap.
26// The secondary present target (ADR-0143): a second surface on this same
27// device, carrying text the shell queues for it. Feature-gated with the text
28// layer it draws through — see the module docs.
29//
30// NOT `aux.rs`: `AUX` is a reserved DOS device name, so on Windows that path
31// cannot be opened by the compiler even though the file creates fine.
32#[cfg(feature = "text")]
33pub mod aux_target;
34pub(crate) mod background;
35pub(crate) mod bloom;
36pub mod capture;
37// The `capture_*` entry points themselves — a continuation of `impl Renderer`
38// (Plan 0061 Phase 3). Private, because it adds no path of its own: every method
39// in it is reached as `Renderer::capture_*`, exactly as before the split.
40mod capture_api;
41pub mod context;
42pub mod feedback;
43// The program preview's intermediate and its letterbox geometry (ADR-0143).
44// NOT feature-gated with `aux_target`: the console that consumes a preview
45// draws text, but the intermediate is a render path, and the property that
46// matters about it is asserted on the headless capture path — which compiles
47// glyphon out.
48pub(crate) mod gpu;
49pub(crate) mod grid;
50pub(crate) mod ink;
51pub(crate) mod kaleidoscope;
52pub mod preview;
53mod preview_readback;
54// The `over`-join blend pass (ADR-0090 / Plan 0076 Phase 3) — driven by the
55// `PostChain`, whose walk knows the junction; nothing else reaches it.
56pub(crate) mod layer_blend;
57pub mod metrics;
58// The now-playing banner (ADR-0110). Deliberately **not** behind the `text`
59// feature: a build without it keeps the state and never asks for a layout, so
60// the plugin build turns the feature on without touching this module.
61pub mod now_playing;
62pub mod overlay;
63mod overlay_font;
64pub mod palette;
65// `pub(crate)` for the same reason the stage modules are: the preset loader's
66// typo check unions every global vocabulary, and since ADR-0085 one of them —
67// `occlude` — belongs to the chain rather than to a stage inside it.
68pub(crate) mod post;
69pub mod scenes;
70#[cfg(feature = "text")]
71pub mod text;
72pub mod tier;
73pub(crate) mod tonemap;
74pub(crate) mod trails;
75mod transition;
76
77use crate::audio::AudioFormat;
78use crate::diag::{AnalysisMetrics, Diag, Metrics};
79use crate::dsp::AnalysisFrame;
80use crate::preset::{
81    Easing, Expr, HoldEdge, LATCH_CAP, Latch, Layer, LayerJoin, Preset, SystemKind, Variables,
82};
83#[cfg(feature = "text")]
84use aux_target::AuxTarget;
85#[cfg(feature = "text")]
86pub use aux_target::{AuxCounts, AuxPresentMode};
87use background::Background;
88pub use capture::{CaptureImage, FrameTap};
89pub use capture_api::AudioCapture;
90pub use context::{AdapterChoice, AdapterDescription, RenderContext, RenderError, list_adapters};
91use ink::Ink;
92use now_playing::NowPlaying;
93use overlay::Overlay;
94use palette::Palette;
95use post::PostChain;
96use scenes::Scene;
97pub use scenes::lines::CapOverflow;
98#[cfg(feature = "text")]
99use text::TextLayer;
100#[cfg(feature = "text")]
101pub use text::TextRun;
102pub use tier::{REFERENCE_PX, Tier, TierConfig, attractor_budget};
103use tonemap::Tonemap;
104use transition::{Blend, DEFAULT_DURATION_SECS, Transition, TransitionKind};
105
106/// The format **every intermediate upstream of the tonemap** carries: linear
107/// light, unbounded above 1.0 (ADR-0046, Plan 0045 Phase 3).
108///
109/// The scene targets, both post stages, the transition blend's two sides and the
110/// tonemap's own input are all this — the surface format stops at the tonemap,
111/// which is where the frame becomes display-referred. Running those
112/// intermediates at the surface's 8 bits instead clips an additive accumulation
113/// per channel at each hand-off: the "additive ceiling" ADR-0046's Context
114/// catalogues, and the reason a bright-pass had nothing correct to bloom from.
115///
116/// `Rgba16Float` rather than 32-bit because it is the format
117/// [`PingPongField`](feedback::PingPongField) ships on (Plan 0014) — proven
118/// blendable and filterable on both backends — and because half
119/// the bandwidth matters on the floor tier (`tier::TierConfig::post_cap`).
120///
121/// Note the arithmetic did **not** change: an 8-bit *sRGB* target already blends
122/// in linear space, so what this buys is headroom above 1.0 and precision, not a
123/// different colour model.
124pub(crate) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
125
126/// Assumed bytes-per-pixel for the swapchain GPU-byte estimate (the common
127/// 8-bit RGBA/BGRA surface formats). An approximation, per ADR-0008.
128const SWAPCHAIN_BYTES_PER_PIXEL: u64 = 4;
129/// Fixed 2-image approximation for the swapchain GPU-byte estimate. wgpu exposes
130/// no real image count, so this stays a constant decoupled from the context's
131/// `desired_maximum_frame_latency` (also 2); the figure is a trend indicator,
132/// not an exact footprint (ADR-0008).
133const SWAPCHAIN_IMAGE_COUNT: u64 = 2;
134
135/// **The engine's whole transition policy** (ADR-0024: policy lives in code, not
136/// in a preset's `[transition]` table and not in an operator UI — both are
137/// deliberate follow-ups). Two constants, in one place, so tuning the show is a
138/// one-line edit that changes *every* switch path at once.
139///
140/// `None` rotates deterministically over [`TransitionKind::LIBRARY`], so a live
141/// show sees the whole library; `Some(kind)` pins every dissolve to one. The
142/// rotation counter is engine state, never a clock or an RNG, so a captured
143/// sequence of switches reproduces exactly (NFR §6).
144const TRANSITION_KIND: Option<TransitionKind> = None;
145/// How long every dissolve runs, in seconds. See [`TRANSITION_KIND`].
146const TRANSITION_DURATION_SECS: f32 = DEFAULT_DURATION_SECS;
147/// Smoothed frame-time ceiling, in milliseconds, under which a dissolve may run
148/// its outgoing side **live** as well (ADR-0024's adaptive governor). A named
149/// constant on purpose — this is the number to calibrate on a low-end rig.
150///
151/// Sized against 60 fps @ 1080p (NFR §1 = 16.7 ms) with slack, not under it: with
152/// vsync on, a machine keeping up reports the refresh interval no matter how much
153/// GPU headroom it has, so a stricter threshold would simply never upgrade on a
154/// 60 Hz display. ~55 fps is the "we are already struggling, do not double the
155/// composite" line. The real protection against *starting* an unaffordable
156/// dissolve is the latch: dual-live begins, the frame time rises past this within
157/// a few frames, and the rest of the dissolve falls back to the frozen side.
158const DUAL_LIVE_BUDGET_MS: f32 = 18.0;
159
160// The five concerns this file keeps out of the `Renderer` (Plan 0126 Phase 2).
161// `routing` answers where a name goes and which scene a system has, `roster` the
162// loaded presets and their per-binding frame state, `evaluate` one frame's bindings,
163// `composite` one side's encode, `tier_governor` the demotion path as an
164// `impl Renderer` continuation. What stays here is the `Renderer` itself.
165mod composite;
166mod evaluate;
167mod roster;
168mod routing;
169mod tier_governor;
170
171use composite::*;
172use evaluate::*;
173pub use roster::ParamError;
174use roster::*;
175use routing::*;
176
177/// How to build a headless [`Renderer`] for capture (Plan 0013).
178///
179/// Deliberately carries **no tier**: [`Renderer::new_headless`] is
180/// [`Tier::Floor`] by construction, which is what keeps every golden baseline
181/// byte-reproducible (ADR-0045). A capture at another tier goes through
182/// [`Renderer::new_headless_tiered`], where the choice is written at the call
183/// site and cannot be reached by forgetting a field.
184#[derive(Debug, Clone, Copy)]
185pub struct HeadlessOptions {
186    /// Offscreen render width in pixels.
187    pub width: u32,
188    /// Offscreen render height in pixels.
189    pub height: u32,
190    /// Force a fallback (software) adapter — WARP on DX12 — so captures
191    /// rasterize identically across machines. Tests want this on.
192    pub prefer_software: bool,
193}
194
195/// Construction-time options for an on-surface [`Renderer`] (Plan 0044).
196///
197/// One field today and a struct anyway, because the tier pin is the first of a
198/// family: a renderer's *construction* choices (quality, later backend or format
199/// preferences) are decided once and never per frame, and threading them as
200/// positional arguments is how the three constructors drift apart.
201#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct RendererOptions {
203    /// An explicit tier pin, or `None` for auto — which resolves [`Tier::Rich`]
204    /// and leaves the frame-time governor free to demote it once (ADR-0045). A
205    /// pin is honoured in both directions and never demotes.
206    pub tier: Option<Tier>,
207    /// Which graphics adapter the window's context asks for.
208    ///
209    /// [`AdapterChoice::Default`] is what the surface path asked for before this
210    /// field existed and stays the default here: an operator's `--gpu` is a
211    /// lever, not a new preference. Changing what an *unflagged* window selects
212    /// would re-base every frame-time figure this project has published, which
213    /// is a measurement question and not an argument-parsing one (ADR-0155).
214    ///
215    /// Carrying a [`AdapterChoice::Named`] `String` is why this struct is
216    /// `Clone` rather than `Copy`.
217    pub adapter: AdapterChoice,
218    /// Which of the tier's two attractor sample ceilings to resolve against
219    /// (ADR-0140). [`SampleBudget::Live`] by default, so every caller that does
220    /// not ask resolves exactly what it resolved before the choice existed.
221    pub budget: SampleBudget,
222}
223
224impl RendererOptions {
225    /// Options pinning `tier` explicitly, on the default adapter.
226    pub fn pinned(tier: Tier) -> Self {
227        Self {
228            tier: Some(tier),
229            ..Self::default()
230        }
231    }
232
233    /// [`pinned`](Self::pinned), against the **offline** sample ceiling — for a
234    /// headless render, which has no present deadline to answer to.
235    pub fn pinned_offline(tier: Tier) -> Self {
236        Self {
237            tier: Some(tier),
238            budget: SampleBudget::Offline,
239            ..Self::default()
240        }
241    }
242}
243
244/// Which of a tier's two sample ceilings this renderer resolves against
245/// (ADR-0140).
246///
247/// The **law is the same either way** — a budget is
248/// `clamp(round(anchor * target_px / REFERENCE_PX), anchor, ceiling)` — and this
249/// picks the `ceiling`. It is a construction choice and never a per-frame one:
250/// the ceiling is also the **allocation**, so changing it means rebuilding the
251/// scene.
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253pub enum SampleBudget {
254    /// Frame-time bound: a window, the plugin's host surface, and every capture
255    /// path — a still, a filmstrip, a report, the golden and sanity suites.
256    ///
257    /// **A capture takes this one even though it has no deadline either**, and
258    /// that is deliberate rather than an oversight: the offline ceiling is a
259    /// larger *allocation*, and `AttractorScene::seed`'s scatter is a function of
260    /// how many particles were asked for, so handing a capture path the offline
261    /// ceiling would move every committed baseline without moving a single
262    /// resolved count.
263    #[default]
264    Live,
265    /// Memory bound: `shot --render`, which walks a clip end to end with `dt`
266    /// injected and answers to no display.
267    Offline,
268}
269
270/// The channel order the four bytes of a pixel arrive in, for a consumer
271/// reading frames off a pipe or a buffer (ADR-0187).
272///
273/// **A closed set of two**, because a frame this engine hands out is 8 bits per
274/// channel in one of the two orders a swapchain negotiates. sRGB and linear
275/// variants of a format are the same order — the transfer function is what a
276/// consumer's own colour handling deals with and the byte layout is what it has
277/// to be told.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum PixelOrder {
280    /// Red, green, blue, alpha — what a headless run always produces, and what
281    /// an `ImageData` on a canvas means by its bytes.
282    Rgba8,
283    /// Blue, green, red, alpha — what a swapchain commonly negotiates on
284    /// Windows, and the order a consumer assuming the other one paints with red
285    /// and blue swapped.
286    Bgra8,
287}
288
289impl PixelOrder {
290    /// The name a frame pipe's announcement carries.
291    pub fn as_str(self) -> &'static str {
292        match self {
293            PixelOrder::Rgba8 => "rgba8",
294            PixelOrder::Bgra8 => "bgra8",
295        }
296    }
297
298    /// Every name, for a consumer enumerating the closed set.
299    pub const ALL: [PixelOrder; 2] = [PixelOrder::Rgba8, PixelOrder::Bgra8];
300
301    /// The order `format` stores its channels in, or `None` where it is not an
302    /// 8-bit four-channel format and so has no order this vocabulary can name.
303    pub(crate) fn of(format: wgpu::TextureFormat) -> Option<Self> {
304        match format {
305            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => {
306                Some(PixelOrder::Rgba8)
307            }
308            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
309                Some(PixelOrder::Bgra8)
310            }
311            _ => None,
312        }
313    }
314}
315
316/// Owns the GPU context, the built-in systems, and the loaded presets; renders
317/// one frame per call by evaluating the active preset into the active system.
318pub struct Renderer {
319    ctx: RenderContext,
320    /// Which sample ceiling this renderer's scenes were built against
321    /// (ADR-0140). Read wherever the scenes are rebuilt — a tier change, a
322    /// capture reset — so a rebuild cannot silently swap the ceiling under a
323    /// run that is already producing frames.
324    budget: SampleBudget,
325    /// Every built-in scene, keyed by the system it drives (see [`SceneRoster`]).
326    scenes: SceneRoster,
327    /// The active preset's composite — its `bg_*` backdrop pre-pass (ADR-0018,
328    /// which owns the frame clear now that scenes `Load` instead of `Clear`) and
329    /// the per-preset [`PostChain`] its trails and kaleidoscope fold through
330    /// (ADR-0018 order, ADR-0031 seam). Each stage is individually skippable, so an
331    /// unbound preset renders straight to the chain's destination.
332    ///
333    /// While a dissolve runs this side stays on the **outgoing** preset — which is
334    /// what keeps its trail accumulating across the dissolve instead of restarting
335    /// — and [`incoming_side`](Self::incoming_side) carries the new one.
336    side: CompositeSide,
337    /// The incoming preset's composite while a dissolve runs, `None` otherwise.
338    /// Created at the switch site and promoted to [`side`](Self::side) at finalize,
339    /// so there is no frame where both or neither is the live one, and no GPU state
340    /// is ever shared between the two presets.
341    incoming_side: Option<CompositeSide>,
342    /// The terminal engine tone-remap (ADR-0028), outside the chain per ADR-0032:
343    /// it remaps the **one** finished frame, so it must run after the transition
344    /// blend of two per-preset composites. Skipped entirely at `ink_amount <= 0`,
345    /// which is every preset that does not opt in.
346    ink: Ink,
347    /// The exposure + tonemap pass (ADR-0046) — the frame's **linear/display
348    /// boundary**, between the transition blend and ink. Unlike every other pass
349    /// it never skips: it is the format seam, not a look, so an unbound preset
350    /// still runs it at `exposure = 1.0`.
351    tonemap: Tonemap,
352    /// The two-input cross-preset blend pass (Plan 0023 / ADR-0032), between the
353    /// chain and the tonemap. Holds no GPU resources between dissolves.
354    blend: Blend,
355    /// The in-flight dissolve, if any. `None` is the ordinary frame path — chain
356    /// straight into ink's input (or the surface), no blend encoded at all.
357    transition: Option<Transition>,
358    /// Dissolves started since the last explicit jump — the rotation position for
359    /// [`TRANSITION_KIND`]. A counter, not a clock or an RNG, so the sequence of
360    /// kinds a run produces is reproducible.
361    transitions_started: u32,
362    /// Loaded presets + the active index (pure selection state — see [`Roster`]).
363    roster: Roster,
364    /// Shared scene clock (seconds), advanced one fixed step per rendered frame.
365    /// The single source for both an expression's `time` and system animation.
366    time: f32,
367    /// Runtime diagnostics: rolling frame-time stats + overlay flags (Plan 0011).
368    diag: Diag,
369    /// The debug overlay pass, painted only while `diag.overlay_enabled()`.
370    overlay: Overlay,
371    /// On-canvas text seam (browse overlay / HUD), standalone-only via the
372    /// `text` feature (ADR-0009); absent from the plugin/default build.
373    #[cfg(feature = "text")]
374    text_layer: TextLayer,
375    /// The secondary present target (ADR-0143), `None` until a shell attaches
376    /// one and again the moment it detaches. Holds a swapchain and a text atlas,
377    /// so the `None` case is the whole cost of the feature while unused.
378    #[cfg(feature = "text")]
379    aux: Option<AuxTarget>,
380    /// The program preview's intermediate (ADR-0143), `None` unless a shell has
381    /// opened one. While it is `Some` the frame is drawn into it and reaches the
382    /// real destination by an exact copy; while it is `None` nothing is
383    /// allocated, no copy is encoded and the frame path is what it was — which
384    /// is what makes the console free when it is closed.
385    preview: Option<preview::PreviewTarget>,
386    /// The preview's non-blocking readback (Plan 0158 Phase 6), `None` unless a
387    /// shell opened one. While it is `Some` each frame drawn through the
388    /// intermediate records one `copy_texture_to_buffer` and polls the previous
389    /// frame's map; while it is `None` nothing is allocated and the frame path
390    /// costs one `Option` test.
391    preview_readback: Option<preview_readback::PreviewReadback>,
392    /// The most recent frame the readback produced and nothing has taken.
393    ///
394    /// One slot rather than a queue: a preview wants the newest picture, and a
395    /// consumer that fell behind is better served by the current frame than by
396    /// the backlog it missed.
397    preview_frame: Option<CaptureImage>,
398    /// The now-playing banner (ADR-0110): a string a shell pushes in, plus the
399    /// `dt`-driven envelope that fades it. Present in every build — a plugin
400    /// build without the `text` feature holds the state and draws nothing.
401    now_playing: NowPlaying,
402    /// Segment-cap truncation from the active preset's last `configure`, if any
403    /// (ADR-0007: the cap is never a silent cut). Refreshed whenever the active
404    /// preset changes; the frontend surfaces it. `None` when geometry fit.
405    cap_overflow: Option<CapOverflow>,
406    /// Scratch for per-element binding evaluation (Plan 0034 Phase 4). Sized
407    /// **once, here at construction**, to the largest element count the loader
408    /// admits — so the per-frame path slices it and never allocates. A frame uses
409    /// the prefix its preset's `[spectrum] elements` asks for; every other system
410    /// uses an empty prefix, which is what makes their path unchanged.
411    series_scratch: Vec<f32>,
412    /// Scratch for per-vertex binding evaluation (Plan 0100 Phase 1). Sized
413    /// **once, here at construction**, to the largest mesh any tier may name
414    /// ([`MAX_MESH`](scenes::warp_mesh::MAX_MESH)), so the per-frame path slices
415    /// it and never allocates. Every system but the warp mesh uses an empty
416    /// prefix.
417    vertex_scratch: Vec<f32>,
418    /// The active preset's per-binding frame state: its easing envelopes
419    /// (ADR-0019) and its sample-and-holds (ADR-0180 rule 2). Reset on every
420    /// active-preset change and capture rebuild, and handed to
421    /// [`outgoing_state`](Self::outgoing_state) at a dissolve's roster flip.
422    param_state: BindingState,
423    /// Live per-name parameter overrides on the active preset (ADR-0176) — what a
424    /// control surface holds while a slider is being dragged, shadowing the
425    /// preset's own binding until it is cleared.
426    ///
427    /// Empty on every path but a driven one, and an empty bank costs the frame a
428    /// slice length check. Cleared on every active-preset change and on every
429    /// `set_presets`, so an override can never outlive the preset it named.
430    overrides: ParamOverrides,
431    /// The active preset's `[latch]` state (ADR-0137). Reset wherever
432    /// [`param_state`](Self::param_state) is, and handed to the outgoing
433    /// bank at the same roster flip — a latch mid-hold keeps reading through a
434    /// dual-live dissolve exactly as an eased param keeps easing.
435    ///
436    /// One bank per preset, not per surface: a latch is preset-level state and
437    /// its `[layer]` bindings read the same event the main scene does, so unlike
438    /// [`layer_state`](Self::layer_state) there is no second one.
439    latches: LatchBank,
440    /// The outgoing preset's `[latch]` state during a dual-live dissolve.
441    outgoing_latches: LatchBank,
442    /// The active preset's **layer** frame state (Plan 0076 Phase 1) — its own,
443    /// because layer bindings are indexed within the layer's `params` and would
444    /// collide with the main preset's indices in
445    /// [`param_state`](Self::param_state). Reset wherever that one is.
446    layer_state: BindingState,
447    /// The outgoing preset's layer frame state during a dual-live dissolve —
448    /// the layer counterpart of [`outgoing_state`](Self::outgoing_state),
449    /// handed over at the same roster flip.
450    outgoing_layer_state: BindingState,
451    /// The active quality tier's capacity values, resolved **once** here at
452    /// construction (ADR-0045). Read at construction and reconfigure time only —
453    /// never branched on per frame.
454    tier: TierConfig,
455    /// True when the tier was pinned explicitly rather than auto-resolved. A pin
456    /// is honoured in both directions (ADR-0045), so the governor never touches
457    /// one — which is also the escape hatch for a machine whose transient stall
458    /// cost it the rich tier.
459    tier_pinned: bool,
460    /// **The governor's one-way latch.** Set the single time the frame-time
461    /// governor demotes `Rich -> Floor`, and never cleared: there is no
462    /// auto-promotion by design, so a demoted session stays demoted and the
463    /// decision cannot oscillate. The same shape as the dual-live freeze latch
464    /// below, for the same reason.
465    ///
466    /// Also what the frontend reads to report the demotion, so a pinned floor and
467    /// a demoted floor are distinguishable — otherwise the demotion would be
468    /// silent, which ADR-0045 rules out.
469    tier_demoted: bool,
470    /// The display's frame budget in seconds, set by the frontend from its
471    /// monitor's refresh rate ([`set_display_hz`](Self::set_display_hz)). Not read
472    /// from the platform here — a refresh rate is a shell concern, and `core`
473    /// stays source- and platform-agnostic.
474    frame_budget_secs: f32,
475    /// The **outgoing** preset's frame state during a dual-live dissolve. Moved
476    /// out of [`param_state`](Self::param_state) when the roster flips, so a
477    /// heavily-smoothed preset keeps easing through the dissolve instead of
478    /// snapping to raw values the moment it stops being active, and a held
479    /// binding keeps showing what it held rather than re-picking a figure on
480    /// the way out.
481    outgoing_state: BindingState,
482}
483
484impl Renderer {
485    /// Everything a renderer is beyond its [`RenderContext`]: the scene roster,
486    /// the composite side, the engine-wide post passes, the overlay, and the
487    /// embedded default presets. **The one construction path** — the three public
488    /// constructors differ only in how they obtain the context, so a new field is
489    /// a one-place edit here rather than three.
490    fn from_context(ctx: RenderContext, opts: RendererOptions) -> Self {
491        // The one tier resolution in the engine (ADR-0045): a pin wins, and
492        // unpinned is `Rich` — the governor's job is to take that back, not to
493        // hedge it here.
494        let tier = TierConfig::for_tier(opts.tier.unwrap_or(Tier::Rich));
495        // Everything upstream of the tonemap is built against COMPOSITE_FORMAT,
496        // not the surface's (ADR-0046): the scenes, both composite sides and the
497        // blend all paint in linear light. Only the tonemap, ink, the overlay and
498        // the text layer write display-referred pixels.
499        let budget = opts.budget;
500        let scenes =
501            crate::render::scenes::create_all(&ctx.device, COMPOSITE_FORMAT, &tier, budget);
502        let side = CompositeSide::new(&ctx.device, COMPOSITE_FORMAT, &tier);
503        let blend = Blend::new(&ctx.device, COMPOSITE_FORMAT);
504        let tonemap = Tonemap::new(&ctx.device, ctx.surface_format());
505        let ink = Ink::new(&ctx.device, ctx.surface_format());
506        let overlay = Overlay::new(&ctx.device, ctx.surface_format());
507        #[cfg(feature = "text")]
508        let text_layer = TextLayer::new(&ctx.device, &ctx.queue, ctx.surface_format());
509        let mut renderer = Self {
510            ctx,
511            budget,
512            scenes,
513            side,
514            incoming_side: None,
515            ink,
516            tonemap,
517            blend,
518            transition: None,
519            transitions_started: 0,
520            roster: Roster::new(crate::preset::default_presets()),
521            time: 0.0,
522            diag: Diag::new(),
523            overlay,
524            #[cfg(feature = "text")]
525            text_layer,
526            #[cfg(feature = "text")]
527            aux: None,
528            preview: None,
529            preview_readback: None,
530            preview_frame: None,
531            now_playing: NowPlaying::default(),
532            cap_overflow: None,
533            series_scratch: vec![0.0; scenes::lines::spectrum::MAX_ELEMENTS],
534            vertex_scratch: vec![0.0; scenes::warp_mesh::vertex_count(scenes::warp_mesh::MAX_MESH)],
535            param_state: BindingState::default(),
536            overrides: ParamOverrides::default(),
537            latches: LatchBank::default(),
538            outgoing_latches: LatchBank::default(),
539            layer_state: BindingState::default(),
540            outgoing_layer_state: BindingState::default(),
541            tier,
542            tier_pinned: opts.tier.is_some(),
543            tier_demoted: false,
544            frame_budget_secs: tier::budget_secs(tier::DEFAULT_DISPLAY_HZ),
545            outgoing_state: BindingState::default(),
546        };
547        // Apply the initial preset's structural config (ADR-0007) so a line
548        // scene at roster index 0 renders with its geometry built.
549        renderer.configure_active_scene();
550        renderer
551    }
552
553    /// Build a renderer drawing into `target` (a safe window handle — the
554    /// standalone path). Starts with the embedded default presets.
555    ///
556    /// `opts` carries the quality-tier pin and the adapter choice;
557    /// [`RendererOptions::default()`](RendererOptions) is auto (rich, governed)
558    /// on whatever adapter wgpu picks for the surface.
559    pub fn new(
560        target: impl Into<wgpu::SurfaceTarget<'static>>,
561        width: u32,
562        height: u32,
563        opts: RendererOptions,
564    ) -> Result<Self, RenderError> {
565        let ctx = RenderContext::new(target, width, height, &opts.adapter)?;
566        Ok(Self::from_context(ctx, opts))
567    }
568
569    /// Build a **headless** renderer that draws into offscreen textures instead
570    /// of a window (Plan 0013 capture tooling). Same scenes, presets, and
571    /// per-frame evaluation as the on-surface path — only the target differs.
572    /// Starts with the embedded default presets.
573    ///
574    /// **Pinned [`Tier::Floor`], and there is no argument to say otherwise**
575    /// (ADR-0045). A capture is a pure function of its inputs (NFR §6), and a
576    /// baseline that moved because the machine that blessed it was fast is not a
577    /// baseline — so the floor is the default by construction here rather than by
578    /// every call site remembering to ask for it. Use
579    /// [`new_headless_tiered`](Self::new_headless_tiered) for a deliberate
580    /// rich-tier capture.
581    pub fn new_headless(opts: HeadlessOptions) -> Result<Self, RenderError> {
582        Self::new_headless_tiered(opts, Tier::Floor)
583    }
584
585    /// A headless renderer pinned to `tier` — the opt-in behind the `shot` CLI's
586    /// `--tier`, for spot-checking that the rich tier's raised budgets actually
587    /// render (Plan 0044 Phase 3). Always **pinned**, so a capture never demotes
588    /// mid-run and stays reproducible.
589    pub fn new_headless_tiered(opts: HeadlessOptions, tier: Tier) -> Result<Self, RenderError> {
590        Self::new_headless_on(opts, tier, &AdapterChoice::from(opts.prefer_software))
591    }
592
593    /// A headless renderer pinned to `tier`, on a **named** adapter (ADR-0146).
594    ///
595    /// The one real headless constructor; the two above delegate here with the
596    /// choice their `prefer_software` flag already implies, so every capture
597    /// path resolves exactly the adapter it resolved before.
598    ///
599    /// A live video-out needs this because the adapter is not a performance
600    /// preference there: a Spout receiver can only open a sender that lives on
601    /// the GPU it renders with, and on a hybrid machine a console process is
602    /// handed the power-saving one. The **sender's** adapter is what that
603    /// constrains; this one is the renderer's, and the two are matched by name
604    /// on each side rather than by a shared index.
605    pub fn new_headless_on(
606        opts: HeadlessOptions,
607        tier: Tier,
608        adapter: &AdapterChoice,
609    ) -> Result<Self, RenderError> {
610        Ok(Self::from_context(
611            RenderContext::new_headless_on(opts.width, opts.height, adapter)?,
612            RendererOptions::pinned(tier),
613        ))
614    }
615
616    /// A headless renderer pinned to `tier` and resolving the **offline** sample
617    /// ceiling (ADR-0140) — the one constructor `shot --render` reaches, and the
618    /// only one in the engine that does.
619    ///
620    /// Separate from [`new_headless_tiered`](Self::new_headless_tiered) rather
621    /// than a flag on it, because the two answer to different bounds and only one
622    /// of them may ever produce a baseline: a render walks a clip with `dt`
623    /// injected and no display to miss, so its ceiling is memory; every other
624    /// headless path is a capture, and a capture takes the live ceiling so the
625    /// allocation — and with it the seeded scatter — is the one every committed
626    /// baseline was blessed against.
627    pub fn new_headless_offline(opts: HeadlessOptions, tier: Tier) -> Result<Self, RenderError> {
628        Ok(Self::from_context(
629            RenderContext::new_headless_on(
630                opts.width,
631                opts.height,
632                &AdapterChoice::from(opts.prefer_software),
633            )?,
634            RendererOptions::pinned_offline(tier),
635        ))
636    }
637
638    /// Renderer targeting a surface the host owns and built the handle for —
639    /// the C ABI path, and the only constructor that does not create its own
640    /// surface. Starts with the embedded default presets (no ABI surface for
641    /// preset selection yet).
642    ///
643    /// **The platform lives on the caller's side of this seam, not here**
644    /// (ADR-0001, ADR-0072): the host knows what kind of window it has and
645    /// builds the [`wgpu::SurfaceTargetUnsafe`] for it, so `core` stays
646    /// source-agnostic and platform-free. `core-cabi` is the one that knows
647    /// about `HWND`.
648    ///
649    /// Auto tier: the plugin gets rich-with-governor, because the C ABI stays v4
650    /// and a plugin-side tier picker is a future ABI question rather than part of
651    /// ADR-0045.
652    ///
653    /// # Safety
654    /// `target`'s handles must be valid and must outlive this renderer.
655    pub unsafe fn new_from_surface_target(
656        target: wgpu::SurfaceTargetUnsafe,
657        width: u32,
658        height: u32,
659    ) -> Result<Self, RenderError> {
660        // The `unsafe` is exactly the surface-from-raw-handle call: the caller's
661        // promise about the handles' validity and lifetime. Construction past
662        // that point is the same safe code the other two paths run.
663        // `RendererOptions::default()` carries `AdapterChoice::Default`, which
664        // is the request this path made before the choice was a parameter — the
665        // shim has no flag surface to select an adapter with, and the C ABI
666        // does not move for this (ADR-0155).
667        let opts = RendererOptions::default();
668        let ctx = unsafe { RenderContext::new_unsafe(target, width, height, &opts.adapter) }?;
669        Ok(Self::from_context(ctx, opts))
670    }
671
672    /// The active quality tier (ADR-0045) — what the diagnostics overlay and the
673    /// `shot` report header name.
674    pub fn tier(&self) -> Tier {
675        self.tier.tier
676    }
677
678    /// **Change the quality tier on the running renderer** (ADR-0054).
679    ///
680    /// Rebuilds the tier-dependent GPU resources — the scene roster and the
681    /// composite side — against the new [`TierConfig`], on the existing
682    /// [`RenderContext`]. The device, queue, surface, preset roster, active
683    /// preset, engine clock, text layer and diagnostics all survive, so the
684    /// operator stays on the preset they were watching. A dissolve in flight is
685    /// dropped: its two sides are GPU state built at the outgoing tier.
686    ///
687    /// The visible cost is one re-accumulation of everything that accumulates —
688    /// trails, reaction-diffusion state, the attractor's deposit. That is the
689    /// correct affordance rather than a defect: the operator asked for this and
690    /// can see that it happened.
691    ///
692    /// **A no-op on a surface-less (headless) context**, so ADR-0045's
693    /// by-construction guarantee that a capture is `Tier::Floor` survives a
694    /// public mutator existing on this type at all. The condition is
695    /// [`tier::tier_change_permitted`], which is a value rather than a comment.
696    ///
697    /// An explicit call **pins** the tier and **clears the governor's demotion
698    /// latch**. The latch means "the governor took a decision the operator did
699    /// not ask for, and must be told about it"; once the operator has asked for
700    /// something, that history is spent. ADR-0045 says the latch is never
701    /// cleared — ADR-0054 narrows that to "never cleared *by the governor*", and
702    /// is the correction of record.
703    pub fn set_tier(&mut self, tier: Tier) {
704        if !tier::tier_change_permitted(self.ctx.surface.is_some()) {
705            return;
706        }
707        self.tier_pinned = true;
708        self.tier_demoted = false;
709        // The same rebuild the governor's demotion runs, reused rather than
710        // open-coded: a tier-sized resource added to a new scene is then covered
711        // by construction instead of by remembering two call sites.
712        self.apply_tier(TierConfig::for_tier(tier));
713    }
714
715    /// The active preset's index in the roster — what the browse overlay opens
716    /// on, and what a caller checks a tier rebuild against.
717    pub fn active_index(&self) -> usize {
718        self.roster.active
719    }
720
721    /// The index the show is **going** to: the dissolve's target while a
722    /// transition is in flight, and [`active_index`](Self::active_index)
723    /// otherwise. This is the "where the show is going, not where it has been"
724    /// convention [`cycle_preset`](Self::cycle_preset) already returns a name by,
725    /// expressed as an index — a host checkmarking a menu wants the user's most
726    /// recent choice to be ticked immediately, not a quarter-second later.
727    ///
728    /// Meaningless on an empty roster (`0`, like `active_index`); a caller that
729    /// distinguishes that case reads [`preset_names`](Self::preset_names) first.
730    pub fn target_preset_index(&self) -> usize {
731        self.transition
732            .as_ref()
733            .map_or(self.roster.active, Transition::incoming_index)
734    }
735
736    /// Whether the frame-time governor demoted this session's tier.
737    ///
738    /// The frontend reports the **transition**, so a demotion is announced once
739    /// rather than shouted every frame — the same pattern
740    /// [`cap_overflow`](Self::cap_overflow) is surfaced through. A pinned floor
741    /// answers `false`; only a governed demotion sets this.
742    pub fn tier_demoted(&self) -> bool {
743        self.tier_demoted
744    }
745
746    /// Tell the renderer the display's refresh rate, which sets the frame budget
747    /// the governor measures against (ADR-0045). Defaults to
748    /// [`DEFAULT_DISPLAY_HZ`](tier::DEFAULT_DISPLAY_HZ); a rate that is not usable
749    /// falls back to it rather than producing a degenerate budget.
750    ///
751    /// Off the hot path — call it at startup and on a monitor change. The core
752    /// does not read this from the platform itself: a refresh rate is a shell
753    /// concern, and the whole point of the split is that `core` knows nothing
754    /// about windows.
755    pub fn set_display_hz(&mut self, hz: f32) {
756        self.frame_budget_secs = tier::budget_secs(hz);
757    }
758
759    /// Reconfigure the surface for a new window size.
760    pub fn resize(&mut self, width: u32, height: u32) {
761        self.ctx.resize(width, height);
762        // The intermediate's copy extent is fixed at construction, so a live
763        // preview is rebuilt at the new size rather than left disagreeing with
764        // the destination it copies into.
765        //
766        // **An open readback is left alone.** It samples the intermediate
767        // through a blit into its own fixed-size tap, so the rebuild below
768        // changes what that blit reads and nothing about the geometry the
769        // readback hands out — which is what lets a consumer be told the size
770        // once, before the first frame, and never again (ADR-0187).
771        if self.preview.is_some() {
772            self.preview = Some(preview::PreviewTarget::new(
773                &self.ctx.device,
774                self.ctx.surface_format(),
775                self.ctx.config.width,
776                self.ctx.config.height,
777            ));
778        }
779    }
780
781    /// Open the program preview: the frame starts being drawn into an
782    /// intermediate and copied to its destination, so a second consumer can
783    /// sample the same pixels the show is getting.
784    ///
785    /// Fails when the destination surface does not accept `COPY_DST`, which is
786    /// the one thing that makes the copy exact. Reported rather than degraded
787    /// to a sampling blit: a preview is worth less than a show whose encoded
788    /// values silently changed.
789    ///
790    /// Idempotent in effect — an already-open preview is rebuilt at the current
791    /// size, which is also how a caller follows a resize it did not see.
792    pub fn open_preview(&mut self) -> Result<(), RenderError> {
793        if !self.ctx.can_copy_to_target() {
794            return Err(RenderError::UnsupportedSurface);
795        }
796        self.preview = Some(preview::PreviewTarget::new(
797            &self.ctx.device,
798            self.ctx.surface_format(),
799            self.ctx.config.width,
800            self.ctx.config.height,
801        ));
802        Ok(())
803    }
804
805    /// Release the intermediate. Idempotent, and the frame path returns to
806    /// drawing straight at its destination on the very next frame.
807    ///
808    /// Takes any readback with it: the readback copies out of the intermediate,
809    /// so one left behind would hold a staging buffer for a texture that no
810    /// longer exists and never yield another frame.
811    pub fn close_preview(&mut self) {
812        self.preview = None;
813        self.preview_readback = None;
814        self.preview_frame = None;
815    }
816
817    /// The open preview's size and identity, or `None` when closed.
818    pub fn preview_state(&self) -> Option<((u32, u32), u64)> {
819        self.preview.as_ref().map(|p| (p.size(), p.generation()))
820    }
821
822    /// **The channel order every frame this renderer hands a consumer carries.**
823    ///
824    /// One answer for every path, because there is one source: the frame tap's
825    /// target, the capture target and the preview's intermediate are all built
826    /// at the context's configured format, so a windowed run reports whatever
827    /// the swapchain negotiated and a headless one reports `HEADLESS_FORMAT`.
828    /// A caller announcing a frame pipe reads this instead of naming a constant
829    /// — a constant is true on one of those two paths and false on the other
830    /// (ADR-0187).
831    ///
832    /// `Err` where that format has no name a consumer knows, which is a refusal
833    /// to publish bytes under a guess.
834    pub fn pixel_order(&self) -> Result<PixelOrder, RenderError> {
835        let format = self.ctx.surface_format();
836        PixelOrder::of(format).ok_or(RenderError::UnnameablePixelOrder(format))
837    }
838
839    /// Attach a **secondary present target** — a second window's surface, on
840    /// this renderer's existing device (ADR-0143).
841    ///
842    /// The core learns nothing about what that window is for. It presents the
843    /// runs it is handed and nothing else; the shell decides their meaning.
844    /// Returns the present mode the surface negotiated, so the caller can log
845    /// which arm ran.
846    ///
847    /// `frame_latency` is the secondary swapchain's
848    /// `desired_maximum_frame_latency`; see [`AuxTarget::new`] for what it paces
849    /// and for the range it is clamped to.
850    ///
851    /// An already-attached target is replaced. An `Err` means this adapter
852    /// cannot drive that surface — the dual-GPU case — and the caller is
853    /// expected to degrade rather than treat it as fatal: the show is on the
854    /// primary surface, which is unaffected.
855    #[cfg(feature = "text")]
856    pub fn attach_aux(
857        &mut self,
858        target: impl Into<wgpu::SurfaceTarget<'static>>,
859        width: u32,
860        height: u32,
861        frame_latency: u32,
862    ) -> Result<AuxPresentMode, RenderError> {
863        let aux = AuxTarget::new(&self.ctx, target, width, height, frame_latency)?;
864        let mode = aux.present_mode();
865        self.aux = Some(aux);
866        Ok(mode)
867    }
868
869    /// The secondary target's configured frame latency, or `None` when detached.
870    #[cfg(feature = "text")]
871    pub fn aux_frame_latency(&self) -> Option<u32> {
872        self.aux.as_ref().map(AuxTarget::frame_latency)
873    }
874
875    /// What the secondary target's present path has done since it was attached,
876    /// or `None` when detached.
877    ///
878    /// The counts live with the target and die with it, so a caller that wants
879    /// a session's totals reads them **before** [`detach_aux`](Self::detach_aux).
880    #[cfg(feature = "text")]
881    pub fn aux_counts(&self) -> Option<AuxCounts> {
882        self.aux.as_ref().map(AuxTarget::counts)
883    }
884
885    /// Release the secondary target, its swapchain and its text atlas. Idempotent.
886    #[cfg(feature = "text")]
887    pub fn detach_aux(&mut self) {
888        self.aux = None;
889    }
890
891    /// Whether a secondary target is currently attached.
892    #[cfg(feature = "text")]
893    pub fn aux_attached(&self) -> bool {
894        self.aux.is_some()
895    }
896
897    /// Resize the secondary target's swapchain. No-op with none attached.
898    #[cfg(feature = "text")]
899    pub fn resize_aux(&mut self, width: u32, height: u32) {
900        if let Some(aux) = self.aux.as_mut() {
901            aux.resize(&self.ctx.device, width, height);
902        }
903    }
904
905    /// The secondary target's size in physical pixels, or `None` when detached.
906    #[cfg(feature = "text")]
907    pub fn aux_size(&self) -> Option<(u32, u32)> {
908        self.aux.as_ref().map(AuxTarget::size)
909    }
910
911    /// Draw `runs` on the secondary target and present it. No-op with none
912    /// attached.
913    ///
914    /// Deliberately **not** called from [`render`](Self::render): the two
915    /// surfaces present independently, so a frame the output drops does not
916    /// have to cost the console one, and neither surface's state can reach the
917    /// other's.
918    ///
919    /// **Independent is not free.** The caller decides when this runs, and in
920    /// the standalone that is the display thread — so a console that stalls
921    /// stalls the loop that called it, whatever the two surfaces do
922    /// separately. The cost is a measurement: Plan 0147 Phase 4 put it inside
923    /// noise across three frame-time regimes on an integrated Radeon, and
924    /// [`aux_counts`](Self::aux_counts) is what makes such a reading
925    /// distinguishable from a console that never presented at all.
926    #[cfg(feature = "text")]
927    pub fn present_aux(&mut self, runs: &[TextRun<'_>]) -> Result<(), RenderError> {
928        match self.aux.as_mut() {
929            Some(aux) => aux.present(&self.ctx, runs, self.preview.as_ref()),
930            None => Ok(()),
931        }
932    }
933
934    /// Queue text runs to composite over the next rendered frame; the queue is
935    /// cleared after each `render`. The standalone fills it each frame with the
936    /// active preset name and, while the browse overlay is open, its rows. A
937    /// `text`-feature (standalone) path — the plugin/default build has no text.
938    #[cfg(feature = "text")]
939    pub fn queue_text(&mut self, runs: &[TextRun<'_>]) {
940        self.text_layer.queue(runs);
941    }
942
943    /// Announce the currently playing track (ADR-0110). The banner fades in,
944    /// holds, and fades out on its own; the caller only says *what*, never
945    /// *when to stop*.
946    ///
947    /// The string is `artist - title`, split on the first ` - `. Setting the
948    /// string that is already set does nothing, so a metadata source may push on
949    /// every update it receives. An empty string clears the banner.
950    ///
951    /// **Source-agnostic by construction** (ADR-0001): the argument carries no
952    /// evidence of whether it came from Windows SMTC or foobar's `titleformat`.
953    /// Callers must not call this from an audio callback — the copy allocates.
954    pub fn set_now_playing(&mut self, text: &str) {
955        self.now_playing.set(text);
956    }
957
958    /// Append the banner's lines to this frame's text queue, after whatever the
959    /// frontend queued — [`queue_text`](Self::queue_text) *replaces* the queue,
960    /// so the core's own furniture has to go in afterwards or a shell that draws
961    /// nothing would erase it.
962    #[cfg(feature = "text")]
963    fn queue_now_playing(&mut self) {
964        // Split-borrowed: the layout reads `now_playing` and `ctx` while the
965        // queue is mutated, which a `&mut self` method call would forbid.
966        let Self {
967            now_playing,
968            text_layer,
969            ctx,
970            ..
971        } = self;
972        let (width, height) = (ctx.config.width as f32, ctx.config.height as f32);
973        for line in now_playing.layout(width, height).into_iter().flatten() {
974            text_layer.push(TextRun {
975                text: &line.text,
976                x: line.x,
977                y: line.y,
978                size: line.size,
979                color: line.color,
980            });
981        }
982    }
983
984    /// Enable or disable rolling frame-time collection — the gated diagnostics
985    /// clock read (Plan 0011). The standalone leaves this on so the title always
986    /// shows live fps/p99; turning it off keeps the core fully clock-free.
987    pub fn enable_diagnostics(&mut self, on: bool) {
988        self.diag.set_collecting(on);
989    }
990
991    /// Turn the on-screen debug overlay on or off (off by default). Independent
992    /// of collection, so the plugin can log metrics without painting the overlay.
993    pub fn set_overlay(&mut self, on: bool) {
994        self.diag.set_overlay(on);
995    }
996
997    /// Whether the debug overlay is currently painted.
998    pub fn overlay_enabled(&self) -> bool {
999        self.diag.overlay_enabled()
1000    }
1001
1002    /// The current diagnostics snapshot (fps, p99, GPU bytes, …).
1003    pub fn metrics(&self) -> Metrics {
1004        self.diag.metrics()
1005    }
1006
1007    /// Median frame time over the diagnostics window, in milliseconds.
1008    ///
1009    /// **Native-only**, beside the analysis snapshot below and for its reason:
1010    /// [`Metrics`] mirrors the C ABI's `RlxMetrics`, so a field added there
1011    /// widens that surface (ADR-0052). A median beside the p99 is what makes a
1012    /// frame-time reading legible — typical against worst — where the mean the
1013    /// snapshot already carries sits between them saying neither.
1014    pub fn frame_ms_p50(&self) -> f32 {
1015        self.diag.frame_ms_p50()
1016    }
1017
1018    /// The last drawn frame's analysis snapshot — the levels and the downbeat
1019    /// lock state. **Native-only**: deliberately absent from the C ABI, so the
1020    /// foobar plugin has no counterpart (ADR-0052).
1021    pub fn analysis_metrics(&self) -> AnalysisMetrics {
1022        self.diag.analysis()
1023    }
1024
1025    /// Name of the currently active preset.
1026    pub fn preset_name(&self) -> &str {
1027        self.roster.name()
1028    }
1029
1030    /// Whether the active GPU adapter is a CPU/software rasterizer (WARP on DX12).
1031    /// Visual-QA tests read this to skip differential checks the software
1032    /// rasterizer can't render faithfully — notably the fullscreen-scene +
1033    /// background-pipeline coexistence, which WARP mis-renders while real hardware
1034    /// renders it correctly (Plan 0025 / ADR-0026).
1035    pub fn adapter_is_software(&self) -> bool {
1036        self.ctx.is_software()
1037    }
1038
1039    /// The active adapter's description — name, backend, device type and driver.
1040    ///
1041    /// **For reports that have to name the machine they were taken on**
1042    /// (ADR-0071): a frame time is a fact about a GPU and a driver rather
1043    /// than about the code, so a cost instrument that prints one has to say
1044    /// which. Read by `core/tests/collage_cost.rs`; nothing on a render path
1045    /// consults it.
1046    pub fn adapter_description(&self) -> &str {
1047        self.ctx.adapter()
1048    }
1049
1050    /// Name of the built-in system the active preset drives (e.g. the frontend
1051    /// shows it next to the preset name).
1052    ///
1053    /// This is the scene's **display** string — `"fragment field"`, with a
1054    /// space. It is not the key anything looks a system up by; see
1055    /// [`Renderer::active_system_key`], which is a different string for every
1056    /// system whose name is more than one word.
1057    pub fn active_system_name(&self) -> &'static str {
1058        self.roster
1059            .active_preset()
1060            .and_then(|p| scene_for(&self.scenes, p.system))
1061            .map(|scene| scene.name())
1062            .unwrap_or("")
1063    }
1064
1065    /// The **canonical key** of the system the active preset drives —
1066    /// `"fragment_field"`, the exact string a preset writes in its `system`
1067    /// field and the schema export labels that system's parameter roster with.
1068    ///
1069    /// Distinct from [`Renderer::active_system_name`] and not interchangeable
1070    /// with it: the two coincide on the four systems whose names are one word
1071    /// (`swarm`, `spectrum`, `emitter`, `attractor`) and differ on every other,
1072    /// so code that resolves a schema roster from the display name works for a
1073    /// quarter of the systems and silently finds nothing for the rest. Anything
1074    /// keyed by system takes this one (ADR-0184).
1075    ///
1076    /// `""` on an empty roster, matching its sibling.
1077    pub fn active_system_key(&self) -> &'static str {
1078        self.roster
1079            .active_preset()
1080            .map(|p| p.system.as_str())
1081            .unwrap_or("")
1082    }
1083
1084    /// The file the active preset was read from, or `None` when it came from
1085    /// the embedded set and has no file on disk.
1086    ///
1087    /// Absolute, as [`crate::preset::load_dir`] recorded it, so a consumer in
1088    /// another process can act on it.
1089    pub fn active_preset_source(&self) -> Option<&std::path::Path> {
1090        self.roster
1091            .active_preset()
1092            .and_then(|p| p.source.as_deref())
1093    }
1094
1095    /// The segment-cap truncation from the active preset's last `configure`, if
1096    /// its geometry hit the fixed cap (ADR-0007: the cap is never a silent cut).
1097    /// Refreshed on every active-preset change (select / cycle / hot-reload); the
1098    /// standalone surfaces it at load. `None` in the normal case where geometry
1099    /// fit — which is every shipped preset.
1100    pub fn cap_overflow(&self) -> Option<&CapOverflow> {
1101        // The configure-time overflow (an oversized L-system depth) takes
1102        // precedence; otherwise the active scene's per-frame geometry-mirror
1103        // overflow (Plan 0018 Phase 4), set once a frame has replicated. Both
1104        // reuse the same `CapOverflow` type so the frontend surfaces either.
1105        if let Some(overflow) = self.cap_overflow.as_ref() {
1106            return Some(overflow);
1107        }
1108        self.roster
1109            .active_preset()
1110            .and_then(|preset| scene_for(&self.scenes, preset.system))
1111            .and_then(|scene| scene.mirror_overflow())
1112    }
1113
1114    /// Draw the current preset for this analysis frame, advancing all animation
1115    /// by `dt` real seconds (Plan 0014 Phase 2). The frontend measures and
1116    /// injects elapsed wall-clock time so the visuals run at the same speed on
1117    /// any refresh rate; `core` never reads a clock. Lost/outdated surfaces
1118    /// self-heal by reconfiguring; timeouts/occlusion skip the frame; only a
1119    /// validation failure (a bug) bubbles up.
1120    pub fn render(&mut self, frame: &AnalysisFrame, dt: f32) -> Result<(), RenderError> {
1121        self.time += dt;
1122        // The banner rides the same injected `dt` the scene does, so it lasts the
1123        // same number of seconds on any refresh rate (ADR-0110 / Plan 0014).
1124        self.now_playing.advance(dt);
1125
1126        // Core-tracked GPU footprint: the swapchain dominates what the core
1127        // allocates. An approximation (ADR-0008), refreshed each frame so it
1128        // tracks resizes and Phase 6's swapchain trim.
1129        self.diag.set_gpu_bytes(
1130            self.ctx.config.width as u64
1131                * self.ctx.config.height as u64
1132                * SWAPCHAIN_BYTES_PER_PIXEL
1133                * SWAPCHAIN_IMAGE_COUNT,
1134        );
1135
1136        let Some(surface_tex) = Self::acquire(&self.ctx)? else {
1137            self.diag.record_dropped(); // transient (timeout/occluded) — skip
1138            return Ok(());
1139        };
1140        let view = surface_tex
1141            .texture
1142            .create_view(&wgpu::TextureViewDescriptor::default());
1143        let mut encoder = self
1144            .ctx
1145            .device
1146            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1147                label: Some("rlx-frame"),
1148            });
1149
1150        // After the acquire, so a dropped frame does not leave a second copy of
1151        // the banner queued behind the one the next frame pushes.
1152        #[cfg(feature = "text")]
1153        self.queue_now_playing();
1154
1155        let (width, height) = (self.ctx.config.width, self.ctx.config.height);
1156        // Moved out of `self` for the draw, which takes `&mut self`; put back
1157        // below. With no preview open this is `None` and the frame is drawn
1158        // straight at the swapchain view, exactly as it was.
1159        let preview = self.preview.take();
1160        // The one live call site: a preset that asked for `seed = "random"` gets
1161        // the salt it drew at load (ADR-0051). Every other caller of `draw_frame`
1162        // is a capture and pins.
1163        let draw_calls = self.draw_frame(
1164            frame,
1165            &mut encoder,
1166            preview.as_ref().map_or(&view, |p| &p.view),
1167            (width, height),
1168            dt,
1169            SaltMode::Live,
1170        );
1171        if let Some(p) = preview.as_ref() {
1172            p.record_copy_to(&mut encoder, &surface_tex.texture);
1173        }
1174        self.preview = preview;
1175        // The readback rides this frame's own submission and takes the previous
1176        // frame's map on the way past, without waiting for either. `false` when
1177        // no readback is open or when the map has not landed.
1178        let recorded = self.step_preview_readback(&mut encoder);
1179
1180        self.ctx.queue.submit(std::iter::once(encoder.finish()));
1181        self.ctx.queue.present(surface_tex);
1182        if recorded {
1183            self.arm_preview_readback();
1184        }
1185
1186        // Free atlas glyphs unused this frame and clear the queue for the next.
1187        #[cfg(feature = "text")]
1188        self.text_layer.end_frame();
1189
1190        self.diag.set_draw_calls(draw_calls);
1191        self.diag.record_frame();
1192        // The quality governor, after this frame is recorded: on sustained
1193        // evidence that the rich tier does not fit the display budget it latches
1194        // to the floor for the remainder of the session (ADR-0045). Cheap in the
1195        // steady state — a pin, an already-floor tier, or a fired latch all return
1196        // before the series is read — and it can rebuild GPU state at most once.
1197        self.govern_tier();
1198        Ok(())
1199    }
1200
1201    /// Record this frame's scene pass — plus the optional text and overlay
1202    /// passes — into `encoder`, drawing into `view` at `width`×`height`. Shared
1203    /// by the on-surface present path and headless capture; the caller owns
1204    /// acquire/submit/present (or the offscreen copy-back). Evaluates the active
1205    /// preset into the active system using the current scene clock
1206    /// (`self.time`, advanced by the caller — this does not touch it) and
1207    /// injects `dt` real seconds into the scene's [`advance`](scenes::Scene::advance)
1208    /// so its simulation steps at the same wall-clock rate on any refresh.
1209    /// `salt` says which of the active preset's two salts its `hash()`/`noise()`
1210    /// calls mix in (ADR-0051) — [`SaltMode::Live`] from the one on-surface
1211    /// caller, [`SaltMode::Pinned`] from every capture path.
1212    ///
1213    /// Returns the draw-call count.
1214    // Eight arguments, one past the lint: they are the frame's inputs and each is
1215    // read once. The same allowance `evaluate_preset` above carries, and for the
1216    // same reason — bundling them would name a struct after a call site rather
1217    // than after anything in the design.
1218    fn draw_frame(
1219        &mut self,
1220        frame: &AnalysisFrame,
1221        encoder: &mut wgpu::CommandEncoder,
1222        view: &wgpu::TextureView,
1223        surface: (u32, u32),
1224        dt: f32,
1225        salt: SaltMode,
1226    ) -> u32 {
1227        // **The one place a frame delta is checked.** Everything below reads this
1228        // value and none of it re-checks: every `Scene::advance`, the composite's
1229        // per-second decay, the transition's own step. A shell can hand over a
1230        // `NaN` (a clock read across a device loss), a zero (two frames inside one
1231        // timer tick) or a negative (a clock that jumped backwards).
1232        //
1233        // The trap it closes is one-way. `Phase::step` is `+= rate * dt` and the
1234        // type has no other mutator, so one non-finite frame poisons an
1235        // accumulator for the life of the process and nothing can ever clear it —
1236        // and a scene that stores `dt` raw carries that into every rate it drives.
1237        // The substitution is `FALLBACK_DT` rather than zero so a degenerate frame
1238        // advances a nominal step instead of freezing the animation. ADR-0152.
1239        let dt = if dt.is_finite() && dt > 0.0 {
1240            dt
1241        } else {
1242            scenes::FALLBACK_DT
1243        };
1244        let Self {
1245            ctx,
1246            // Read where the scenes are BUILT, not where they are drawn - a
1247            // frame never consults it.
1248            budget: _,
1249            scenes,
1250            side,
1251            incoming_side,
1252            ink,
1253            tonemap,
1254            blend,
1255            transition,
1256            roster,
1257            time,
1258            diag,
1259            overlay,
1260            #[cfg(feature = "text")]
1261            text_layer,
1262            // Advanced and queued in `render`, before this is called — the banner
1263            // is a live-surface concern, so a headless capture never draws one.
1264            now_playing: _,
1265            // Set at preset load, surfaced by the frontend — not a per-frame concern.
1266            cap_overflow: _,
1267            // Stepped either side of the submission in `render`, which is where
1268            // the encoder and the queue both are; a frame encode never sees them.
1269            preview_readback: _,
1270            preview_frame: _,
1271            // The caller decided which view this frame draws into and owns the
1272            // copy out of it; from in here the intermediate is just the target.
1273            preview: _,
1274            series_scratch,
1275            vertex_scratch,
1276            param_state,
1277            overrides,
1278            layer_state,
1279            outgoing_state,
1280            outgoing_layer_state,
1281            latches,
1282            outgoing_latches,
1283            // Resolved once at construction; the overlay names it (ADR-0045). The
1284            // capacity values themselves were consumed at construction time — the
1285            // frame path reads the tier only to print it.
1286            tier,
1287            // Whether that tier was the governor's doing, so the overlay can tell
1288            // a demoted floor from a pinned one.
1289            tier_demoted,
1290            // Governor inputs, read after the frame is encoded (see `govern_tier`)
1291            // rather than while encoding it — not a per-frame drawing concern.
1292            tier_pinned: _,
1293            frame_budget_secs: _,
1294            // Switch-site policy state (the kind rotation) — not a per-frame concern.
1295            transitions_started: _,
1296            // The secondary surface presents on its own encoder in `present_aux`,
1297            // never from inside a frame encode: the output's pixels must not
1298            // depend on whether a console is attached (ADR-0143).
1299            #[cfg(feature = "text")]
1300                aux: _,
1301        } = self;
1302
1303        // The analysis snapshot for the overlay and the 1 Hz log (ADR-0052).
1304        // Taken here rather than in `render` so a capture records it too, and
1305        // before the early return below so it is the frame's own values even when
1306        // there is no preset to draw them under.
1307        diag.set_analysis(frame);
1308
1309        let Some(preset) = roster.active_preset() else {
1310            return 0; // no presets loaded — nothing to draw
1311        };
1312        let routes = roster.active_routes();
1313
1314        // Evaluate against the shared clock and this frame's analysis. Both sides
1315        // of a dissolve read the same variables — they differ in what they *bind*.
1316        //
1317        // The band array rides along **by borrow** (ADR-0036): this bundle is
1318        // built once per frame here but read once per binding below, so a
1319        // by-value spectrum would put a 256-byte copy on the per-binding path.
1320        //
1321        // Through `from_frame` rather than the nine positional arguments, so the
1322        // harness probe that reads the same frame cannot bind it differently.
1323        //
1324        // The one thing that is *not* shared is the salt (ADR-0051): it is a fact
1325        // about a preset, not about the audio, so each side re-salts this bundle
1326        // below with its own. Sharing it would put the incoming preset's seed on
1327        // the outgoing preset's `hash()` for the second a dissolve lasts.
1328        let vars = Variables::from_frame(frame, *time);
1329
1330        // Fixed-order composite (ADR-0018/0028/0032/0046): background (owns the
1331        // clear) -> scene -> the per-preset post chain -> [blend] -> tonemap ->
1332        // ink -> present. Everything left of the tonemap is linear light at
1333        // `COMPOSITE_FORMAT`; everything right of it is display-referred at the
1334        // surface's. Where the scene draws and which chain stage folds into which
1335        // is the chain's business, not the renderer's — see `post.rs` for the
1336        // order and the skip rule. The blend, the tonemap and ink are engine-wide
1337        // passes the renderer drives.
1338        // The **render target's** aspect, which `PostChain::begin` reports as the
1339        // surface's whatever internal grid the chain routes through (ADR-0037).
1340        // Computed once here because the per-vertex evaluation below happens
1341        // before the chain opens, and `rad`/`ang` must be aspect-corrected.
1342        let surface_aspect = surface.0 as f32 / surface.1.max(1) as f32;
1343        let mut draw_calls = 0;
1344        // What both sides evaluate through. Held across the two `evaluate_side`
1345        // calls below rather than rebuilt, so the scratch buffers are sliced from
1346        // one owner and the salt cannot be taken from two different bundles.
1347        let mut shared = SideInputs {
1348            tier,
1349            series: series_scratch,
1350            vertex: vertex_scratch,
1351            aspect: surface_aspect,
1352            vars,
1353            frame,
1354            time: *time,
1355            dt,
1356            salt,
1357        };
1358
1359        // The outgoing side first, because it feeds the blend.
1360        let dual_live = transition.as_ref().is_some_and(Transition::is_dual_live);
1361        if dual_live {
1362            draw_calls += encode_outgoing_side(
1363                ctx,
1364                encoder,
1365                surface,
1366                Outgoing {
1367                    transition: transition.as_ref(),
1368                    roster,
1369                    blend,
1370                    scenes,
1371                    side,
1372                    state: outgoing_state,
1373                    layer_state: outgoing_layer_state,
1374                    latches: outgoing_latches,
1375                },
1376                &mut shared,
1377            );
1378        }
1379
1380        // --- the active preset: the incoming side during a dissolve, the only
1381        // side otherwise ---
1382        //
1383        // The opening frame is the exception that makes the whole scheme cheap: the
1384        // roster still points at the *outgoing* preset there, so this one ordinary
1385        // composite is the snapshot, and `side` is the right chain for it.
1386        let live_side = match incoming_side.as_mut() {
1387            Some(incoming) if !transition.as_ref().is_some_and(Transition::needs_snapshot) => {
1388                incoming
1389            }
1390            _ => side,
1391        };
1392        let Some(scene) = scene_for_mut(scenes, preset.system) else {
1393            return draw_calls;
1394        };
1395        draw_calls += encode_active_side(
1396            ctx,
1397            encoder,
1398            view,
1399            surface,
1400            ActiveSide {
1401                active: Active {
1402                    preset,
1403                    routes,
1404                    // The only side that takes them: the active preset is the one
1405                    // a control surface is addressing (ADR-0176).
1406                    overrides: Some(overrides),
1407                },
1408                scene,
1409                composite: live_side,
1410                state: param_state,
1411                layer_state,
1412                latches,
1413            },
1414            DisplayTail {
1415                blend,
1416                tonemap,
1417                ink,
1418                transition: transition.as_ref(),
1419            },
1420            &mut shared,
1421        );
1422
1423        // Hold the outgoing preset's evaluated terminal params off the capture
1424        // frame, where the roster still points at it — the one frame they exist.
1425        let captured_ink = ink.params();
1426        let captured_exposure = tonemap.exposure();
1427
1428        draw_calls += encode_on_canvas(
1429            ctx,
1430            encoder,
1431            view,
1432            surface,
1433            OnCanvas {
1434                #[cfg(feature = "text")]
1435                text_layer,
1436                diag,
1437                overlay,
1438                tier: tier.tier,
1439                tier_demoted: *tier_demoted,
1440            },
1441        );
1442
1443        // The borrows above all end here, so `self` is free again (NLL).
1444        self.advance_transition(dt, dual_live, captured_ink, captured_exposure);
1445
1446        draw_calls
1447    }
1448
1449    fn acquire(ctx: &RenderContext) -> Result<Option<wgpu::SurfaceTexture>, RenderError> {
1450        use wgpu::CurrentSurfaceTexture as C;
1451        let Some(surface) = ctx.surface.as_ref() else {
1452            return Ok(None); // headless context — no swapchain to present into
1453        };
1454        match surface.get_current_texture() {
1455            C::Success(t) | C::Suboptimal(t) => Ok(Some(t)),
1456            C::Timeout | C::Occluded => Ok(None),
1457            C::Outdated | C::Lost => {
1458                ctx.reconfigure();
1459                match surface.get_current_texture() {
1460                    C::Success(t) | C::Suboptimal(t) => Ok(Some(t)),
1461                    C::Validation => Err(RenderError::SurfaceValidation),
1462                    _ => Ok(None),
1463                }
1464            }
1465            C::Validation => Err(RenderError::SurfaceValidation),
1466        }
1467    }
1468}
1469
1470#[cfg(test)]
1471mod tests;
1472
1473#[cfg(test)]
1474mod milk_wash;