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