rlx_core/milk/mod.rs
1//! The MilkDrop runtime: compiled EEL2 programs, the machine that executes them,
2//! and the driver that turns their output into warp-mesh parameters (ADR-0113).
3//!
4//! **No `.milk` text, no HLSL and no translator is anywhere in this module.**
5//! Conversion happens ahead of time in `milkconv`, which never ships; what
6//! reaches a binary is bytecode, a stack VM, and the driver below.
7//!
8//! # The execution model, which is MilkDrop's
9//!
10//! A bundle carries three programs over **one shared register file**:
11//!
12//! 1. `per_frame_init` runs **once**, when the preset loads. It is where a preset
13//! seeds the `q` variables and its `megabuf`.
14//! 2. `per_frame` runs **once per frame**, after the host has written this frame's
15//! audio and clock into the input registers. What it leaves in the output
16//! registers is the whole-mesh transform, and what it leaves in `q1`–`q32` is
17//! the bridge to the program below.
18//! 3. `per_vertex` runs **once per mesh vertex**, starting each time from the
19//! register state `per_frame` left — so `q1` reads the same value at every
20//! vertex, and a write inside the program does not leak from one vertex to the
21//! next.
22//!
23//! That third property is why [`EelProgram::written_registers`] exists: the
24//! restore is over the registers the program can actually write, not over the
25//! whole file, which at thousands of vertices per frame is the difference between
26//! a memcpy that matters and one that does not.
27//!
28//! # Rates: MilkDrop is per frame, this engine is per second
29//!
30//! **The single most consequential translation in the conversion, and it is here
31//! rather than in the converter.** MilkDrop's `zoom`, `rot`, `dx`, `dy`, `warp`
32//! and `decay` are all *per rendered frame*: a preset written on a machine
33//! running 30 fps drifts at half the speed on one running 60. This engine's
34//! vocabulary is per second throughout (ADR-0019), which is what makes a look
35//! identical on any display.
36//!
37//! So the driver converts, per frame, using the frame's own measured `dt`:
38//! a factor becomes `v^fps` and a rate becomes `v * fps`. A preset authored
39//! against MilkDrop's nominal **30 fps** therefore moves at the speed its author
40//! saw, on any refresh — and a converted preset does not have to carry the
41//! assumption in its bytecode. [`NOMINAL_FPS`] is the frame rate the `fps`
42//! *variable* reports to the program, for the same reason: a preset that reads
43//! `fps` and divides by it is compensating for a cadence, and telling it the
44//! truth about a 144 Hz display would double-compensate.
45
46// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to core/src/milk by
47// Plan 0100 Phase 2). The driver runs per vertex per frame.
48#![deny(
49 clippy::unwrap_used,
50 clippy::expect_used,
51 clippy::indexing_slicing,
52 clippy::panic,
53 clippy::unreachable
54)]
55
56pub mod bytecode;
57pub mod outputs;
58pub mod shader;
59pub mod vm;
60
61use bytecode::{EelProgram, ProgramError};
62use outputs::{
63 FrameOutputs, FrameSlots, ShapeInstance, ShapeInstanceSlots, WavePoint, WavePointSlots,
64};
65use vm::{Budget, VmState};
66
67/// The frame rate the `fps` variable reports, and the cadence a per-frame rate is
68/// interpreted against.
69///
70/// MilkDrop's own nominal rate. A `.milk` preset's `zoom = 1.01` means "1 % per
71/// frame at about this rate", and its author tuned it by eye there — so this is
72/// the number that reproduces what they saw, not the display's actual refresh.
73/// See the module docs.
74pub const NOMINAL_FPS: f32 = 30.0;
75
76/// The nine per-vertex outputs, in the order
77/// [`warp_mesh::PER_VERTEX_PARAMS`](crate::render::scenes::warp_mesh::PER_VERTEX_PARAMS)
78/// declares them — the same roster, because they *are* the same roster. A
79/// converted preset and a hand-authored `[per_vertex]` table drive one scene.
80const OUTPUT_NAMES: [&str; 9] = ["zoom", "rot", "cx", "cy", "dx", "dy", "sx", "sy", "warp"];
81
82/// Whether output `i` is a **factor** (composed multiplicatively over time, so it
83/// converts as `v^fps`) or a **rate** (`v * fps`). Positional with
84/// [`OUTPUT_NAMES`].
85///
86/// `cx`/`cy` are neither: they are a *position*, not a motion, so they pass
87/// through untouched. `false` here with a `false` in [`OUTPUT_RATE`] means that.
88const OUTPUT_FACTOR: [bool; 9] = [true, false, false, false, false, false, true, true, false];
89/// Whether output `i` is a rate — see [`OUTPUT_FACTOR`].
90const OUTPUT_RATE: [bool; 9] = [false, true, false, false, true, true, false, false, true];
91
92/// How many `q` variables bridge the main program to a custom wave or shape.
93///
94/// MilkDrop's own count. The bridge is a **copy**, not a shared file: each
95/// element has its own register space (its own `t1`-`t8`, its own working
96/// variables), and what crosses is `q1`-`q32` after the main per-frame program
97/// has run. Copying is what keeps an element from writing back into the main
98/// program's state, which the reference also forbids.
99pub const Q_COUNT: usize = 32;
100
101/// A converted preset's compiled programs: what a bundle carries beyond an
102/// ordinary Ritmolux preset.
103///
104/// Cloned into the warp mesh's structural config at load and never touched again
105/// — the runtime state lives in [`MilkRuntime`], not here, so the same bundle can
106/// drive two scenes (the roster's and a `[layer]`'s) without them sharing a
107/// register file.
108#[derive(Debug, Clone, PartialEq)]
109pub struct MilkBundle {
110 /// Run once at preset load.
111 pub per_frame_init: EelProgram,
112 /// Run once per frame.
113 pub per_frame: EelProgram,
114 /// Run once per mesh vertex.
115 pub per_vertex: EelProgram,
116 /// Up to four custom waves — extra traces the preset draws with their own
117 /// per-point programs (Plan 0100 Phase 4). **47 % of the corpus enables at
118 /// least one**, which is why they are here rather than warned about.
119 pub waves: Vec<MilkElement>,
120 /// Up to four custom shapes — filled polygons with their own per-instance
121 /// programs. **63 % of the corpus enables at least one.**
122 pub shapes: Vec<MilkElement>,
123 /// The translated MilkDrop 2 `warp` shader, as a complete WGSL fragment
124 /// module (Plan 0100 Phase 6). `None` — most of MilkDrop 1.x, and any
125 /// MilkDrop 2 preset that never wrote one — takes the engine's built-in
126 /// decay path. Validated through naga at load ([`shader::validate_wgsl`]).
127 pub warp_wgsl: Option<String>,
128 /// The translated `comp` shader — replaces the built-in present remaps when
129 /// present. See [`warp_wgsl`](Self::warp_wgsl).
130 pub comp_wgsl: Option<String>,
131 /// The deepest `GetBlur`/`sampler_blur` level either shader reaches,
132 /// `0..=3`. Zero means the blur chain never runs for this preset.
133 pub blur_level: u8,
134 /// How many levels this bundle's **feedback field** quantizes to at the end
135 /// of the warp pass (ADR-0118), defaulting to [`DEFAULT_QUANTIZE_STEPS`].
136 ///
137 /// **The presence of a bundle is what turns this on**, which is the whole
138 /// per-bundle shape: the reference's 8-bit target truncates a `decay`-scaled
139 /// dim pixel to zero and this engine's `Rgba16Float` field does not, so an
140 /// imported preset wants the emulation and a native `warp_mesh` world — which
141 /// carries no bundle and so never reaches this field — does not.
142 ///
143 /// `0.0` is off. Negative selects ADR-0118's Alternative D (floor to zero at
144 /// one step, no ladder between). Both are reachable from `[milk]
145 /// quantize_steps`, so the look gate's A/B is a preset edit rather than a
146 /// re-convert.
147 pub quantize_steps: f32,
148}
149
150/// The 8-bit feedback target every MilkDrop preset was authored against
151/// (ADR-0118): 256 levels, 255 steps between black and white.
152pub const DEFAULT_QUANTIZE_STEPS: f32 = 255.0;
153
154/// What a custom element draws, which decides which of its programs run and how
155/// its outputs are read.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ElementKind {
158 /// A custom **wave**: `count` points, each from one run of `per_point`,
159 /// stroked as a polyline or scattered as dots.
160 Wave,
161 /// A custom **shape**: `instances` filled polygons, each from one run of
162 /// `per_frame` with `instance` bound.
163 Shape,
164}
165
166/// One custom wave or shape: its three programs and the structural numbers that
167/// size its geometry.
168///
169/// The *look* numbers — position, colour, radius, alpha — are **not** here: they
170/// are outputs the element's own per-frame program leaves in named registers,
171/// seeded from the file's initial conditions by a prologue the converter emits.
172/// That is the same shape the main bundle takes, and it is what keeps this struct
173/// from being forty fields of `.milk` key.
174#[derive(Debug, Clone, PartialEq)]
175pub struct MilkElement {
176 /// Run once, at preset load.
177 pub init: EelProgram,
178 /// Run once per frame — and once per *instance* for a shape, with
179 /// `instance` bound.
180 pub per_frame: EelProgram,
181 /// Run once per point. Empty for a shape.
182 pub per_point: EelProgram,
183 /// Points (a wave) or sides (a shape).
184 pub count: u32,
185 /// How many copies a shape draws. Always `1` for a wave.
186 pub instances: u32,
187 /// Which it is.
188 pub kind: ElementKind,
189 /// Past `0.5`, a wave draws dots rather than a line.
190 pub use_dots: bool,
191 /// Past `0.5`, a wave's line or a shape's outline is drawn thick.
192 pub thick: bool,
193 /// Past `0.5`, a wave adds rather than blends. See [`ElementSpec::additive`]
194 /// for why a shape's flag is not here.
195 pub additive: bool,
196}
197
198/// The most points a custom wave may draw, and the most sides a shape may have.
199///
200/// MilkDrop's own limits are 512 points and 100 sides. A wave's points each cost
201/// one run of its per-point program on the render thread, so the bound matters
202/// for the same reason the mesh grid's does — and unlike the mesh it is not a
203/// tier capacity, because it is the *preset* that names it and a converted preset
204/// should draw the figure its author drew.
205pub const MAX_WAVE_POINTS: u32 = 512;
206/// See [`MAX_WAVE_POINTS`].
207pub const MAX_SHAPE_SIDES: u32 = 100;
208/// The most copies of one custom shape a preset may draw. MilkDrop's own limit.
209pub const MAX_SHAPE_INSTANCES: u32 = 1024;
210/// How many custom waves, and how many custom shapes, the `.milk` format
211/// declares. Exactly four of each — not a budget, the format's own shape.
212pub const MAX_ELEMENTS: usize = 4;
213
214/// What is wrong with a bundle, as a surfaced load error.
215#[derive(Debug, Clone, PartialEq)]
216pub enum BundleError {
217 /// One of the three programs did not decode.
218 Program {
219 /// Which section — `per_frame_init`, `per_frame` or `per_vertex`.
220 section: &'static str,
221 /// Why.
222 err: ProgramError,
223 },
224 /// The three programs declare different register rosters, so a `q1` written
225 /// by one would not be the `q1` the next reads.
226 RosterMismatch {
227 /// The section whose roster differs from `per_frame`'s.
228 section: &'static str,
229 },
230 /// More custom waves or shapes than the `.milk` format allows.
231 TooManyElements {
232 /// `"wave"` or `"shape"`.
233 which: &'static str,
234 },
235}
236
237impl std::fmt::Display for BundleError {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 match self {
240 BundleError::Program { section, err } => write!(f, "[milk] {section}: {err}"),
241 BundleError::RosterMismatch { section } => write!(
242 f,
243 "[milk] {section} declares a different .regs roster from per_frame. \
244 The three programs share one register file — that sharing IS the \
245 q1..q32 bridge — so they must declare the same registers in the \
246 same order. `milkconv` emits them that way; a hand-written bundle \
247 has to as well."
248 ),
249 BundleError::TooManyElements { which } => write!(
250 f,
251 "[milk] more than {MAX_ELEMENTS} custom {which}s. The .milk format \
252 declares exactly four of each, so a fifth is a bundle this \
253 converter did not write."
254 ),
255 }
256 }
257}
258
259impl std::error::Error for BundleError {}
260
261impl MilkBundle {
262 /// Decode a bundle from the three assembly sections. An absent section is the
263 /// empty program, which runs nothing.
264 pub fn from_assembly(
265 per_frame_init: Option<&str>,
266 per_frame: Option<&str>,
267 per_vertex: Option<&str>,
268 ) -> Result<Self, BundleError> {
269 let decode = |section: &'static str, text: Option<&str>| match text {
270 None => Ok(EelProgram::empty()),
271 Some(text) => {
272 EelProgram::from_assembly(text).map_err(|err| BundleError::Program { section, err })
273 }
274 };
275 let bundle = Self {
276 per_frame_init: decode("per_frame_init", per_frame_init)?,
277 per_frame: decode("per_frame", per_frame)?,
278 per_vertex: decode("per_vertex", per_vertex)?,
279 waves: Vec::new(),
280 shapes: Vec::new(),
281 warp_wgsl: None,
282 comp_wgsl: None,
283 blur_level: 0,
284 quantize_steps: DEFAULT_QUANTIZE_STEPS,
285 };
286 // The shared register file is the bridge, so the rosters have to agree.
287 // An empty program declares nothing and is exempt.
288 for (section, program) in [
289 ("per_frame_init", &bundle.per_frame_init),
290 ("per_vertex", &bundle.per_vertex),
291 ] {
292 if program.register_count() > 0
293 && bundle.per_frame.register_count() > 0
294 && program.names() != bundle.per_frame.names()
295 {
296 return Err(BundleError::RosterMismatch { section });
297 }
298 }
299 Ok(bundle)
300 }
301
302 /// Attach one custom wave or shape, decoded from its own three assembly
303 /// sections.
304 ///
305 /// **Its own register file, not the bundle's** — an element's programs share
306 /// a scope with each other and with nothing else (see `ElementRuntime`), so
307 /// the roster check here is *within* the element and there is deliberately no
308 /// comparison against `per_frame`'s. Only `q1`-`q32` cross, by copy.
309 ///
310 /// Silently over-count is not an option: `count` and `instances` are what the
311 /// draw layer's buffers were sized from, so they are clamped to the format's
312 /// own limits by `ElementRuntime::spec` on the way out rather than trusted.
313 #[allow(clippy::too_many_arguments)]
314 pub fn push_element(
315 &mut self,
316 kind: ElementKind,
317 init: Option<&str>,
318 per_frame: Option<&str>,
319 per_point: Option<&str>,
320 count: u32,
321 instances: u32,
322 use_dots: bool,
323 thick: bool,
324 additive: bool,
325 ) -> Result<(), BundleError> {
326 let which = match kind {
327 ElementKind::Wave => "wave",
328 ElementKind::Shape => "shape",
329 };
330 let decode = |section: &'static str, text: Option<&str>| match text {
331 None => Ok(EelProgram::empty()),
332 Some(text) => {
333 EelProgram::from_assembly(text).map_err(|err| BundleError::Program { section, err })
334 }
335 };
336 let element = MilkElement {
337 init: decode("element init", init)?,
338 per_frame: decode("element per_frame", per_frame)?,
339 per_point: decode("element per_point", per_point)?,
340 count,
341 instances,
342 kind,
343 use_dots,
344 thick,
345 additive,
346 };
347 for (section, program) in [
348 ("element init", &element.init),
349 ("element per_point", &element.per_point),
350 ] {
351 if program.register_count() > 0
352 && element.per_frame.register_count() > 0
353 && program.names() != element.per_frame.names()
354 {
355 return Err(BundleError::RosterMismatch { section });
356 }
357 }
358 match kind {
359 ElementKind::Wave => self.waves.push(element),
360 ElementKind::Shape => self.shapes.push(element),
361 }
362 // The format's own ceiling of four each. A fifth would compile fine and
363 // then draw, which is not what the source preset asked for.
364 let full = match kind {
365 ElementKind::Wave => self.waves.len(),
366 ElementKind::Shape => self.shapes.len(),
367 };
368 if full > MAX_ELEMENTS {
369 return Err(BundleError::TooManyElements { which });
370 }
371 Ok(())
372 }
373
374 /// Whether any program in the bundle draws from the RNG.
375 pub fn uses_random(&self) -> bool {
376 let element = |e: &MilkElement| {
377 e.init.uses_random() || e.per_frame.uses_random() || e.per_point.uses_random()
378 };
379 self.per_frame_init.uses_random()
380 || self.per_frame.uses_random()
381 || self.per_vertex.uses_random()
382 || self.waves.iter().any(element)
383 || self.shapes.iter().any(element)
384 }
385
386 /// The roster the three programs share, for resolving indices once at load.
387 fn roster(&self) -> &[String] {
388 if self.per_frame.register_count() > 0 {
389 self.per_frame.names()
390 } else if self.per_vertex.register_count() > 0 {
391 self.per_vertex.names()
392 } else {
393 self.per_frame_init.names()
394 }
395 }
396}
397
398/// The register indices the host writes before a per-frame run.
399///
400/// Every field is an `Option`: a program that never names `treb` has no register
401/// for it, and writing one that does not exist is a no-op rather than an error.
402/// Resolved **once at load** — nothing per frame looks a name up.
403#[derive(Debug, Default, Clone, Copy)]
404struct FrameInputs {
405 bass: Option<u16>,
406 mid: Option<u16>,
407 treb: Option<u16>,
408 bass_att: Option<u16>,
409 mid_att: Option<u16>,
410 treb_att: Option<u16>,
411 time: Option<u16>,
412 frame: Option<u16>,
413 fps: Option<u16>,
414 progress: Option<u16>,
415 meshx: Option<u16>,
416 meshy: Option<u16>,
417 aspectx: Option<u16>,
418 aspecty: Option<u16>,
419}
420
421/// The register indices the host writes before each per-vertex run.
422#[derive(Debug, Default, Clone, Copy)]
423struct VertexInputs {
424 x: Option<u16>,
425 y: Option<u16>,
426 rad: Option<u16>,
427 ang: Option<u16>,
428}
429
430/// One loaded bundle's live state: the VM's arena and the resolved indices.
431///
432/// Built at preset load, borrowed mutably per frame, and **never resized while a
433/// preset renders** — the whole real-time claim.
434pub struct MilkRuntime {
435 bundle: MilkBundle,
436 state: VmState,
437 inputs: FrameInputs,
438 vertex_inputs: VertexInputs,
439 /// The nine per-vertex output registers, positionally with [`OUTPUT_NAMES`].
440 outputs: [Option<u16>; 9],
441 /// Every named per-frame output beyond the nine — the composite roster and
442 /// the whole draw layer (`outputs::FrameOutputs`).
443 frame_slots: FrameSlots,
444 /// The registers `q1`-`q32` live in, for the copy into each element.
445 q_slots: [Option<u16>; Q_COUNT],
446 /// One live state per custom wave, then one per custom shape.
447 waves: Vec<ElementRuntime>,
448 /// See [`waves`](Self::waves).
449 shapes: Vec<ElementRuntime>,
450 /// The render target's aspect as of the last [`run_frame`](Self::run_frame),
451 /// so [`run_vertex`](Self::run_vertex) can compute MilkDrop's `rad`/`ang`
452 /// without the caller having to hand it over per vertex.
453 aspect: f32,
454 /// The register values `per_frame` left, for the registers `per_vertex` can
455 /// write. Restored before each vertex (see the module docs).
456 snapshot: Vec<f32>,
457 /// Monotone frame counter, for the `frame` variable. Reset with the preset.
458 frame_index: u32,
459 /// Slow envelopes behind `bass_att`/`mid_att`/`treb_att`, which MilkDrop
460 /// supplies and this engine's analysis frame does not carry. One-pole on the
461 /// injected real `dt`, so they are frame-rate independent like everything
462 /// else here.
463 att: [f32; 3],
464 /// The MilkDrop-scaled band levels of the last [`run_frame`](Self::run_frame),
465 /// kept so the shader uniform reads exactly what the EEL program read.
466 last_bands: [f32; 3],
467 /// The preset's salt, kept for the two `rand_*` shader vectors.
468 salt: u32,
469 /// The shader-side `rand_frame` stream's state — a counter mixed per frame,
470 /// so a capture replaying the same frames sees the same randoms (ADR-0051).
471 shader_rand_state: u32,
472 /// This frame's `rand_frame` vector, advanced by `run_frame`.
473 shader_rand_frame: [f32; 4],
474}
475
476/// The time constant of the `*_att` envelopes, in seconds.
477///
478/// MilkDrop describes them as "an attenuated (smoothed) version" without naming a
479/// constant. Half a second is the value that behaves the way presets use them —
480/// as a slow floor under a percussive band, so `bass / bass_att` reads as "louder
481/// than it has been lately". Short enough to follow a build, long enough not to
482/// follow a kick.
483const ATT_TAU: f32 = 0.5;
484
485/// What MilkDrop's `bass` reads at an average level.
486///
487/// Its bands are normalized so ~1.0 is typical and a loud passage reaches 2–3.
488/// This engine's are `0..1` against their own recent peak (ADR-0049), where ~0.5
489/// is typical. Doubling puts a typical passage at MilkDrop's typical, which is
490/// what a preset's thresholds were tuned against.
491const BAND_SCALE: f32 = 2.0;
492
493impl MilkRuntime {
494 /// Build a runtime for `bundle`, resolving every index and running
495 /// `per_frame_init` once.
496 ///
497 /// `salt` is the preset's (ADR-0051): the pinned twin on every capture path
498 /// and the live one in the app, so a bundle using `rand()` is reproducible in
499 /// the harness and varied in the app.
500 pub fn new(bundle: MilkBundle, salt: u32) -> Self {
501 let roster = bundle.roster().to_vec();
502 let index = |name: &str| -> Option<u16> {
503 roster
504 .iter()
505 .position(|n| n == name)
506 .and_then(|i| u16::try_from(i).ok())
507 };
508 let inputs = FrameInputs {
509 bass: index("bass"),
510 mid: index("mid"),
511 treb: index("treb"),
512 bass_att: index("bass_att"),
513 mid_att: index("mid_att"),
514 treb_att: index("treb_att"),
515 time: index("time"),
516 frame: index("frame"),
517 fps: index("fps"),
518 progress: index("progress"),
519 meshx: index("meshx"),
520 meshy: index("meshy"),
521 aspectx: index("aspectx"),
522 aspecty: index("aspecty"),
523 };
524 let vertex_inputs = VertexInputs {
525 x: index("x"),
526 y: index("y"),
527 rad: index("rad"),
528 ang: index("ang"),
529 };
530 let outputs = std::array::from_fn(|i| OUTPUT_NAMES.get(i).and_then(|n| index(n)));
531 let frame_slots = FrameSlots::resolve(&index);
532 let q_slots = std::array::from_fn(|i| index(&format!("q{}", i + 1)));
533 let waves: Vec<ElementRuntime> = bundle
534 .waves
535 .iter()
536 .map(|e| ElementRuntime::new(e, salt))
537 .collect();
538 let shapes: Vec<ElementRuntime> = bundle
539 .shapes
540 .iter()
541 .map(|e| ElementRuntime::new(e, salt))
542 .collect();
543 let stack = bundle
544 .per_frame_init
545 .stack_depth()
546 .max(bundle.per_frame.stack_depth())
547 .max(bundle.per_vertex.stack_depth());
548 let mut state = VmState::new(roster.len(), stack, salt);
549 state.accommodate(&bundle.per_frame_init);
550 state.accommodate(&bundle.per_frame);
551 state.accommodate(&bundle.per_vertex);
552 let snapshot = vec![0.0; bundle.per_vertex.written_registers().len()];
553 let mut runtime = Self {
554 bundle,
555 state,
556 inputs,
557 vertex_inputs,
558 outputs,
559 frame_slots,
560 q_slots,
561 waves,
562 shapes,
563 aspect: 1.0,
564 snapshot,
565 frame_index: 0,
566 att: [0.0; 3],
567 last_bands: [0.0; 3],
568 salt,
569 shader_rand_state: 0,
570 shader_rand_frame: [0.0; 4],
571 };
572 runtime.reset();
573 runtime
574 }
575
576 /// Reset to the state a freshly-loaded preset is in: registers and arenas
577 /// zeroed, RNG back at its seed, frame counter at zero, `per_frame_init` run
578 /// once.
579 ///
580 /// **What makes a capture reproducible.** The harness rebuilds a preset from
581 /// the top, and everything the previous run left — a `megabuf` a program
582 /// filled, an RNG stream it advanced — has to go with it (NFR §6).
583 pub fn reset(&mut self) {
584 self.state.clear_registers();
585 self.state.clear_memory();
586 self.state.reset_rng();
587 self.frame_index = 0;
588 self.att = [0.0; 3];
589 self.last_bands = [0.0; 3];
590 self.shader_rand_state = 0;
591 self.shader_rand_frame = [0.0; 4];
592 vm::run(&self.bundle.per_frame_init, &mut self.state, Budget::INIT);
593 for element in self.waves.iter_mut().chain(self.shapes.iter_mut()) {
594 element.reset();
595 }
596 }
597
598 /// Whether this bundle has a per-vertex program at all. A bundle without one
599 /// drives the mesh from its per-frame outputs alone, which is a perfectly
600 /// good MilkDrop preset.
601 pub fn has_per_vertex(&self) -> bool {
602 !self.bundle.per_vertex.code().is_empty()
603 }
604
605 /// Run `per_frame` for this frame and return the whole-mesh outputs, already
606 /// converted from MilkDrop's per-frame rates to this engine's per-second ones
607 /// (module docs).
608 ///
609 /// Returns `(outputs, decay)`, positionally with `OUTPUT_NAMES`. `decay` is
610 /// `None` when the program never names it, so the scene keeps its own default
611 /// rather than being handed a zero.
612 pub fn run_frame(
613 &mut self,
614 frame: &crate::dsp::AnalysisFrame,
615 time: f32,
616 dt: f32,
617 mesh: (u32, u32),
618 aspect: f32,
619 ) -> ([f32; 9], FrameOutputs) {
620 self.aspect = if aspect.is_finite() && aspect > 0.0 {
621 aspect
622 } else {
623 1.0
624 };
625 // The `*_att` envelopes, on the injected real `dt` — finite and positive
626 // by the renderer entry's substitution (ADR-0191), so not re-checked.
627 let alpha = 1.0 - (-dt / ATT_TAU).exp();
628 for (slot, level) in self.att.iter_mut().zip([frame.bass, frame.mid, frame.treb]) {
629 *slot += alpha * (level * BAND_SCALE - *slot);
630 }
631
632 let set = |state: &mut VmState, slot: Option<u16>, value: f32| {
633 if let Some(index) = slot {
634 state.set(index, value);
635 }
636 };
637 self.last_bands = [
638 frame.bass * BAND_SCALE,
639 frame.mid * BAND_SCALE,
640 frame.treb * BAND_SCALE,
641 ];
642 // The shader-side `rand_frame`, mixed from the frame counter rather than
643 // drawn from a stream — replaying the same frame numbers replays the
644 // same randoms whatever else ran in between (NFR §6).
645 self.shader_rand_state = self.frame_index.wrapping_add(1);
646 self.shader_rand_frame = std::array::from_fn(|i| {
647 unit01(mix32(
648 self.salt.wrapping_add(
649 self.shader_rand_state
650 .wrapping_mul(4)
651 .wrapping_add(i as u32),
652 ),
653 ))
654 });
655 set(&mut self.state, self.inputs.bass, frame.bass * BAND_SCALE);
656 set(&mut self.state, self.inputs.mid, frame.mid * BAND_SCALE);
657 set(&mut self.state, self.inputs.treb, frame.treb * BAND_SCALE);
658 set(&mut self.state, self.inputs.bass_att, self.att[0]);
659 set(&mut self.state, self.inputs.mid_att, self.att[1]);
660 set(&mut self.state, self.inputs.treb_att, self.att[2]);
661 set(&mut self.state, self.inputs.time, time);
662 set(&mut self.state, self.inputs.frame, self.frame_index as f32);
663 set(&mut self.state, self.inputs.fps, NOMINAL_FPS);
664 // `progress` is "how far through this preset's time slice", which this
665 // engine has no equivalent of — presets rotate on a transition rather
666 // than on a timer. Zero rather than absent: a preset reading it gets a
667 // defined value, and Phase 3's roster note says which of these are
668 // supplied rather than guessed.
669 set(&mut self.state, self.inputs.progress, 0.0);
670 set(&mut self.state, self.inputs.meshx, mesh.0 as f32);
671 set(&mut self.state, self.inputs.meshy, mesh.1 as f32);
672 // MilkDrop's aspect pair, in its own convention: the LONGER axis reads 1
673 // and the shorter one reads the ratio, which is what makes `x * aspectx`
674 // an isotropic coordinate.
675 let (ax, ay) = if aspect >= 1.0 {
676 (1.0, aspect)
677 } else {
678 (1.0 / aspect.max(1e-4), 1.0)
679 };
680 set(&mut self.state, self.inputs.aspectx, ax);
681 set(&mut self.state, self.inputs.aspecty, ay);
682
683 // The outputs start at the identity, so a program that writes only some
684 // of them leaves the rest still rather than at zero.
685 for (i, slot) in self.outputs.iter().enumerate() {
686 if let Some(index) = *slot {
687 self.state.set(index, identity_output(i));
688 }
689 }
690 self.frame_slots.seed(&mut self.state);
691
692 vm::run(&self.bundle.per_frame, &mut self.state, Budget::FRAME);
693 self.frame_index = self.frame_index.wrapping_add(1);
694
695 // Snapshot what `per_vertex` can write, so each vertex starts from here.
696 for (slot, index) in self
697 .snapshot
698 .iter_mut()
699 .zip(self.bundle.per_vertex.written_registers())
700 {
701 *slot = self.state.get(*index);
702 }
703
704 let raw: [f32; 9] = std::array::from_fn(|i| {
705 self.outputs
706 .get(i)
707 .and_then(|slot| *slot)
708 .map_or_else(|| identity_output(i), |index| self.state.get(index))
709 });
710 let frame_outputs = self.frame_slots.read(&self.state);
711
712 // **The q-bridge into the elements**, and it is a copy rather than a
713 // shared file (see `Q_COUNT`): each custom wave and shape has its own
714 // register space, and what crosses is `q1`-`q32` as the main per-frame
715 // program left them. Done here, once, rather than per point or per
716 // instance.
717 let mut q = [0.0f32; Q_COUNT];
718 for (slot, index) in q.iter_mut().zip(self.q_slots) {
719 if let Some(index) = index {
720 *slot = self.state.get(index);
721 }
722 }
723 for element in self.waves.iter_mut().chain(self.shapes.iter_mut()) {
724 element.begin_frame(&q, time, frame, self.att);
725 }
726
727 (convert_outputs(raw), frame_outputs)
728 }
729
730 /// How many custom waves this bundle carries.
731 pub fn wave_count(&self) -> usize {
732 self.waves.len()
733 }
734
735 /// The structural numbers of custom wave `index` — how many points it draws
736 /// and how it strokes them.
737 pub fn wave_spec(&self, index: usize) -> Option<ElementSpec> {
738 self.waves.get(index).map(ElementRuntime::spec)
739 }
740
741 /// The structural numbers of custom shape `index`.
742 pub fn shape_spec(&self, index: usize) -> Option<ElementSpec> {
743 self.shapes.get(index).map(ElementRuntime::spec)
744 }
745
746 /// How many custom shapes this bundle carries.
747 pub fn shape_count(&self) -> usize {
748 self.shapes.len()
749 }
750
751 /// Run custom wave `index`'s per-point program for the point at `sample`
752 /// (`0..1` along the wave) with `value1`/`value2` bound to the audio there,
753 /// and return where it put the point.
754 ///
755 /// `left` and `right` are MilkDrop's `value1` and `value2` — channels 0 and 1
756 /// of the analyzer's levelled pair (`AnalysisFrame::waveform_pair`,
757 /// ADR-0199). They are two genuinely different numbers on a stereo stream, so
758 /// a preset that plots one against the other draws the figure its author saw
759 /// rather than the diagonal line a mono stand-in gives; on a one-channel
760 /// stream they are equal, because the pair fills both slots from channel 0.
761 pub fn run_wave_point(
762 &mut self,
763 index: usize,
764 sample: f32,
765 left: f32,
766 right: f32,
767 ) -> Option<WavePoint> {
768 let element = self.waves.get_mut(index)?;
769 Some(element.run_point(sample, left, right))
770 }
771
772 /// Run custom wave `index`'s per-frame program, once, before its points.
773 pub fn run_wave_frame(&mut self, index: usize) -> Option<()> {
774 self.waves.get_mut(index)?.run_frame();
775 Some(())
776 }
777
778 /// Run custom shape `index`'s per-frame program for one instance and return
779 /// where it put that copy.
780 pub fn run_shape_instance(&mut self, index: usize, instance: u32) -> Option<ShapeInstance> {
781 let element = self.shapes.get_mut(index)?;
782 Some(element.run_instance(instance))
783 }
784
785 /// Run `per_vertex` for the vertex at uv `(x, y)` — `y = 0` at the **top**,
786 /// which is the reference's own convention — and return its nine outputs,
787 /// converted like the per-frame ones.
788 ///
789 /// `x` and `y` are aspect-corrected here rather than passed through, and
790 /// `rad` and `ang` are computed rather than taken — all three deliberately:
791 /// **MilkDrop normalizes the whole per-vertex input set differently from this
792 /// engine's native `[per_vertex]` vocabulary**, and a converted preset has to
793 /// get MilkDrop's.
794 /// The reference takes `rad = |(x_ndc * aspectx, y_ndc * aspecty)|` with the
795 /// *longer* axis scaled to 1, so `rad` reaches `1.0` at the middle of the
796 /// left and right edges of a wide frame; the native `rad`
797 /// ([`warp_mesh::vertex_position`](crate::render::scenes::warp_mesh::vertex_position))
798 /// reaches `1.0` at the top and bottom instead. The two differ by a factor of
799 /// the aspect, which on a 16:9 display is 1.78 — enough that a preset written
800 /// as `zoom = 1 + rad * 0.1` would be most of a stop out. `ang` is the
801 /// reference's `atan2` in `-pi..pi`, not the native `0..tau`.
802 ///
803 /// `x` and `y` are the same correction applied to the uv itself: the
804 /// reference hands the program `x * 0.5 * aspectx + 0.5` and
805 /// `y * -0.5 * aspecty + 0.5` off the clip position, so on a 16:9 frame `x`
806 /// still spans `0..1` and `y` spans only `0.5 +/- 0.28125` — the shorter axis
807 /// is compressed, and the pair is the frame's shape rather than its extent.
808 /// The native vocabulary's `vertex_position` spans `0..1` on both axes, so a
809 /// converted preset reading `y` through that would drive its stretch or its
810 /// translation 1.78 times too far. The same corrected space is where the warp
811 /// chain's middle stages run (ADR-0212), which is what keeps the program's
812 /// inputs and the transform they feed in one set of units.
813 ///
814 /// Restores the per-frame register state first, so a write inside the program
815 /// does not leak into the next vertex — MilkDrop's semantics, and the reason
816 /// two adjacent vertices of an identical program give identical answers.
817 pub fn run_vertex(&mut self, x: f32, y: f32) -> [f32; 9] {
818 // Clip-space position, +y up, which is what the reference's `rad`/`ang`
819 // are taken from.
820 let nx = x * 2.0 - 1.0;
821 let ny = 1.0 - y * 2.0;
822 let (ax, ay) = if self.aspect >= 1.0 {
823 (1.0, 1.0 / self.aspect)
824 } else {
825 (self.aspect, 1.0)
826 };
827 let (px, py) = (nx * ax, ny * ay);
828 let rad = (px * px + py * py).sqrt();
829 let ang = py.atan2(px);
830 // Back to a 0..1-centred uv from the corrected clip pair, y down —
831 // `px * 0.5 + 0.5` and `-py * 0.5 + 0.5` are the reference's own two
832 // expressions with the `aspect` factor already in `px`/`py`.
833 let (sx, sy) = (px * 0.5 + 0.5, 0.5 - py * 0.5);
834 for (value, index) in self
835 .snapshot
836 .iter()
837 .zip(self.bundle.per_vertex.written_registers())
838 {
839 self.state.set(*index, *value);
840 }
841 if let Some(index) = self.vertex_inputs.x {
842 self.state.set(index, sx);
843 }
844 if let Some(index) = self.vertex_inputs.y {
845 self.state.set(index, sy);
846 }
847 if let Some(index) = self.vertex_inputs.rad {
848 self.state.set(index, rad);
849 }
850 if let Some(index) = self.vertex_inputs.ang {
851 self.state.set(index, ang);
852 }
853 vm::run(&self.bundle.per_vertex, &mut self.state, Budget::VERTEX);
854 let raw: [f32; 9] = std::array::from_fn(|i| {
855 self.outputs
856 .get(i)
857 .and_then(|slot| *slot)
858 .map_or_else(|| identity_output(i), |index| self.state.get(index))
859 });
860 convert_outputs(raw)
861 }
862
863 // --- the shader input surface (Plan 0100 Phase 6) ---
864 //
865 // Read after `run_frame` by the scene's uniform fill, so a converted shader
866 // sees the same frame the EEL programs saw.
867
868 /// `q1`..`q32` as the per-frame program left them.
869 pub fn q_values(&self) -> [f32; Q_COUNT] {
870 std::array::from_fn(|i| {
871 self.q_slots
872 .get(i)
873 .and_then(|slot| *slot)
874 .map_or(0.0, |index| self.state.get(index))
875 })
876 }
877
878 /// `bass, mid, treb, vol` then their attenuated four, MilkDrop-scaled.
879 /// `vol` is the mean of the three — this engine's analysis has no separate
880 /// loudness, and the mean behaves the way presets use `vol`.
881 pub fn shader_bands(&self) -> [f32; 8] {
882 let [b, m, t] = self.last_bands;
883 let [ba, ma, ta] = self.att;
884 let vol = (b + m + t) / 3.0;
885 let vol_att = (ba + ma + ta) / 3.0;
886 [b, m, t, vol, ba, ma, ta, vol_att]
887 }
888
889 /// This frame's `rand_frame` vector — four uniform randoms, fresh per frame,
890 /// a pure function of the salt and the frame index.
891 pub fn rand_frame(&self) -> [f32; 4] {
892 self.shader_rand_frame
893 }
894
895 /// The preset-lifetime `rand_preset` vector, fixed at load from the salt.
896 pub fn rand_preset(&self) -> [f32; 4] {
897 std::array::from_fn(|i| unit01(mix32(self.salt.wrapping_mul(0x9E37_79B9) ^ (i as u32))))
898 }
899
900 /// The frame counter, for the shader's `frame`.
901 pub fn frame_index(&self) -> u32 {
902 self.frame_index
903 }
904
905 /// The preset's salt, for shader inputs derived outside the runtime (the
906 /// `rot_*` matrices).
907 pub fn salt(&self) -> u32 {
908 self.salt
909 }
910}
911
912/// One round of the lowbias32 mixer — the CPU mirror of `gpu::HASH_WGSL`, here
913/// because `milk` sits below `render` and cannot reach the crate-private one.
914fn mix32(v: u32) -> u32 {
915 let mut h = v;
916 h ^= h >> 16;
917 h = h.wrapping_mul(0x7FEB_352D);
918 h ^= h >> 15;
919 h = h.wrapping_mul(0x846C_A68B);
920 h ^= h >> 16;
921 h
922}
923
924/// The top 24 bits as a unit fraction in `[0, 1)`.
925fn unit01(h: u32) -> f32 {
926 (h >> 8) as f32 / 16_777_216.0
927}
928
929/// The structural numbers a custom element's geometry is sized from — the parts
930/// of a [`MilkElement`] the draw layer needs and the VM does not.
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub struct ElementSpec {
933 /// Points (a wave) or sides (a shape), already clamped to the format's own
934 /// limit.
935 pub count: u32,
936 /// How many copies a shape draws; `1` for a wave.
937 pub instances: u32,
938 /// Past `0.5` in the source, a wave draws dots.
939 pub use_dots: bool,
940 /// Past `0.5` in the source, the stroke is thick.
941 pub thick: bool,
942 /// Past `0.5` in the source, a wave **adds** rather than blends.
943 ///
944 /// A wave's flag is a file key (`wavecode_N_bAdditive`) and so is per element;
945 /// a *shape*'s is a register its own per-frame program may write, so that one
946 /// lives on [`ShapeInstance`] instead.
947 pub additive: bool,
948}
949
950/// One custom wave's or shape's live state (Plan 0100 Phase 4).
951///
952/// **Its own register file, its own arenas, its own RNG.** MilkDrop gives each
953/// element a separate variable scope — its own `t1`-`t8`, its own working
954/// variables — and only `q1`-`q32` cross from the main program, by copy. Sharing
955/// one file instead would let a shape's `t1` collide with a wave's, which the
956/// reference's own presets rely on not happening.
957struct ElementRuntime {
958 program: MilkElement,
959 state: VmState,
960 inputs: ElementInputs,
961 /// Where a wave's per-point outputs land.
962 point: WavePointSlots,
963 /// Where a shape's per-instance outputs land.
964 instance: ShapeInstanceSlots,
965 /// The registers `q1`-`q32` occupy **in this element's own file**, which the
966 /// bridge copies into.
967 q_slots: [Option<u16>; Q_COUNT],
968 /// The values a **shape's** per-frame program can write, saved before the
969 /// instance loop and restored before each instance — the same mechanism and
970 /// the same reason as the mesh's per-vertex snapshot.
971 ///
972 /// **Empty for a wave**, which is not an oversight: a wave's per-point
973 /// program walks its trace carrying state forward, and restoring between
974 /// points is what made *chasers 19 Portal*'s mirror inert. See
975 /// [`run_point`](Self::run_point).
976 snapshot: Vec<f32>,
977 /// The registers that snapshot covers — a shape's per-frame written set, and
978 /// nothing at all for a wave.
979 snapshot_of: Vec<u16>,
980}
981
982/// The read-only variables an element's programs are handed.
983#[derive(Debug, Default, Clone, Copy)]
984struct ElementInputs {
985 time: Option<u16>,
986 frame: Option<u16>,
987 fps: Option<u16>,
988 bass: Option<u16>,
989 mid: Option<u16>,
990 treb: Option<u16>,
991 bass_att: Option<u16>,
992 mid_att: Option<u16>,
993 treb_att: Option<u16>,
994 /// A wave's position along its own length, `0..1`.
995 sample: Option<u16>,
996 /// The audio at that position — MilkDrop's left and right channels, which
997 /// are the same number here (see `MilkRuntime::run_wave_point`).
998 value1: Option<u16>,
999 /// See [`value1`](Self::value1).
1000 value2: Option<u16>,
1001 /// Which copy of a shape this run is for, from `0`.
1002 instance: Option<u16>,
1003}
1004
1005impl ElementRuntime {
1006 fn new(program: &MilkElement, salt: u32) -> Self {
1007 let roster: Vec<String> = if program.per_frame.register_count() > 0 {
1008 program.per_frame.names().to_vec()
1009 } else if program.per_point.register_count() > 0 {
1010 program.per_point.names().to_vec()
1011 } else {
1012 program.init.names().to_vec()
1013 };
1014 let index = |name: &str| -> Option<u16> {
1015 roster
1016 .iter()
1017 .position(|n| n == name)
1018 .and_then(|i| u16::try_from(i).ok())
1019 };
1020 let stack = program
1021 .init
1022 .stack_depth()
1023 .max(program.per_frame.stack_depth())
1024 .max(program.per_point.stack_depth());
1025 let mut state = VmState::new(roster.len(), stack, salt);
1026 state.accommodate(&program.init);
1027 state.accommodate(&program.per_frame);
1028 state.accommodate(&program.per_point);
1029 // **A shape's instances are independent; a wave's points are not.**
1030 //
1031 // A shape loops its per-FRAME program once per instance and each copy
1032 // starts from what the frame left, so its snapshot is that program's
1033 // written set. A wave loops its per-POINT program along its own length
1034 // and **carries state from one point to the next** — that is the
1035 // reference's semantics and the corpus is built on it, so a wave takes
1036 // no snapshot at all. See `run_point`.
1037 let snapshot_of = match program.kind {
1038 ElementKind::Wave => Vec::new(),
1039 ElementKind::Shape => program.per_frame.written_registers().to_vec(),
1040 };
1041 let mut runtime = Self {
1042 program: program.clone(),
1043 state,
1044 inputs: ElementInputs {
1045 time: index("time"),
1046 frame: index("frame"),
1047 fps: index("fps"),
1048 bass: index("bass"),
1049 mid: index("mid"),
1050 treb: index("treb"),
1051 bass_att: index("bass_att"),
1052 mid_att: index("mid_att"),
1053 treb_att: index("treb_att"),
1054 sample: index("sample"),
1055 value1: index("value1"),
1056 value2: index("value2"),
1057 instance: index("instance"),
1058 },
1059 point: WavePointSlots::resolve(&index),
1060 instance: ShapeInstanceSlots::resolve(&index),
1061 q_slots: std::array::from_fn(|i| index(&format!("q{}", i + 1))),
1062 snapshot: vec![0.0; snapshot_of.len()],
1063 snapshot_of,
1064 };
1065 runtime.reset();
1066 runtime
1067 }
1068
1069 /// The structural numbers, clamped to the format's own limits so a bundle
1070 /// cannot ask for geometry the buffers were not sized for.
1071 fn spec(&self) -> ElementSpec {
1072 let (max_count, max_instances) = match self.program.kind {
1073 ElementKind::Wave => (MAX_WAVE_POINTS, 1),
1074 ElementKind::Shape => (MAX_SHAPE_SIDES, MAX_SHAPE_INSTANCES),
1075 };
1076 ElementSpec {
1077 count: self.program.count.clamp(2, max_count),
1078 instances: self.program.instances.clamp(1, max_instances),
1079 use_dots: self.program.use_dots,
1080 thick: self.program.thick,
1081 additive: self.program.additive,
1082 }
1083 }
1084
1085 /// Back to the state a freshly-loaded preset is in.
1086 fn reset(&mut self) {
1087 self.state.clear_registers();
1088 self.state.clear_memory();
1089 self.state.reset_rng();
1090 vm::run(&self.program.init, &mut self.state, Budget::INIT);
1091 }
1092
1093 /// Copy the bridge in and bind this frame's inputs. Called once per frame,
1094 /// after the main per-frame program has run.
1095 fn begin_frame(
1096 &mut self,
1097 q: &[f32; Q_COUNT],
1098 time: f32,
1099 frame: &crate::dsp::AnalysisFrame,
1100 att: [f32; 3],
1101 ) {
1102 for (value, index) in q.iter().zip(self.q_slots) {
1103 if let Some(index) = index {
1104 self.state.set(index, *value);
1105 }
1106 }
1107 let mut set = |slot: Option<u16>, value: f32| {
1108 if let Some(index) = slot {
1109 self.state.set(index, value);
1110 }
1111 };
1112 set(self.inputs.time, time);
1113 set(self.inputs.fps, NOMINAL_FPS);
1114 set(self.inputs.bass, frame.bass * BAND_SCALE);
1115 set(self.inputs.mid, frame.mid * BAND_SCALE);
1116 set(self.inputs.treb, frame.treb * BAND_SCALE);
1117 set(self.inputs.bass_att, att[0]);
1118 set(self.inputs.mid_att, att[1]);
1119 set(self.inputs.treb_att, att[2]);
1120 }
1121
1122 /// A wave's per-frame program, run once before its points. Its outputs seed
1123 /// the per-point defaults, which is how a wave whose per-point code sets only
1124 /// `x`/`y` still gets its colour.
1125 fn run_frame(&mut self) {
1126 self.point.seed(&mut self.state);
1127 vm::run(&self.program.per_frame, &mut self.state, Budget::FRAME);
1128 self.take_snapshot();
1129 }
1130
1131 /// One point of a custom wave.
1132 ///
1133 /// **Nothing is restored between points, and that is the semantics rather
1134 /// than an omission** (Plan 0108 Phase 5). A wave's per-point program walks
1135 /// the trace carrying its own working variables forward, which is what lets
1136 /// a preset alternate, accumulate, or integrate along the wave. The idiom
1137 /// the corpus is full of is a two-state counter:
1138 ///
1139 /// ```text
1140 /// flip = flip + 1;
1141 /// flip = flip * below(flip, 2); // 1, 0, 1, 0, ... down the trace
1142 /// yp = (flip * 0.1 - 0.05) * sample;
1143 /// ```
1144 ///
1145 /// Reset the registers before each point and those three lines compute a
1146 /// **constant** — the mirrored pair collapses to a single trace and the
1147 /// preset's symmetry silently never happens, which is design-backlog 0107's
1148 /// *chasers 19 Portal* converting cleanly and rendering inert.
1149 ///
1150 /// Measured over the 10 347-file corpus, 2026-08-17: **6 347 files carry a
1151 /// custom-wave per-point program, and 3 368 of them (53 %) read a per-point
1152 /// variable before writing it with nothing in the file seeding it** — so
1153 /// only carry-over can supply a value. 612 of those use `flip` by name.
1154 ///
1155 /// This is the opposite of [`MilkRuntime::run_vertex`], which *does* restore,
1156 /// and the two are not inconsistent: the mesh's per-vertex program is a pure
1157 /// function of its vertex in the reference, and a wave's per-point program
1158 /// is a walk along a line.
1159 ///
1160 /// # The carry reaches past the end of the trace, too
1161 ///
1162 /// Nothing reseeds a working register at the **frame** boundary either
1163 /// (Plan 0108 Mode 4 review, 2026-08-17). [`run_frame`](Self::run_frame)
1164 /// seeds only the named wave-point *outputs* — `WavePointSlots`'s `x`, `y`,
1165 /// `r`, `g`, `b`, `a` — and a wave's `snapshot_of` is empty, so `flip`
1166 /// survives from the last point of one frame into the first point of the
1167 /// next. On an **even**-length trace the two-state counter returns to where
1168 /// it started and this is invisible; on an odd one the figure comes out
1169 /// inverted every other frame, which reads as an alternation at the
1170 /// **display's** refresh rate rather than at any authored one.
1171 ///
1172 /// That is believed faithful — the reference allocates a custom element's
1173 /// variable space once and only its `init` code reseeds it — but it is a
1174 /// claim about the reference and is **not verified against it**; Plan 0108's
1175 /// Phase 6 is where `foo_vis_milk2` answers it. It is pinned meanwhile by
1176 /// `milkconv/tests/draw_layer.rs`'s
1177 /// `a_waves_per_point_state_also_carries_across_the_frame_boundary`, so the
1178 /// behaviour cannot move without this comment moving with it.
1179 fn run_point(&mut self, sample: f32, left: f32, right: f32) -> WavePoint {
1180 if let Some(index) = self.inputs.sample {
1181 self.state.set(index, sample);
1182 }
1183 if let Some(index) = self.inputs.value1 {
1184 self.state.set(index, left);
1185 }
1186 if let Some(index) = self.inputs.value2 {
1187 self.state.set(index, right);
1188 }
1189 vm::run(&self.program.per_point, &mut self.state, Budget::VERTEX);
1190 self.point.read(&self.state)
1191 }
1192
1193 /// One instance of a custom shape.
1194 fn run_instance(&mut self, instance: u32) -> ShapeInstance {
1195 self.restore_snapshot();
1196 self.instance.seed(&mut self.state);
1197 if let Some(index) = self.inputs.instance {
1198 self.state.set(index, instance as f32);
1199 }
1200 if let Some(index) = self.inputs.frame {
1201 self.state.set(index, instance as f32);
1202 }
1203 vm::run(&self.program.per_frame, &mut self.state, Budget::FRAME);
1204 self.instance.read(&self.state)
1205 }
1206
1207 fn take_snapshot(&mut self) {
1208 for (slot, index) in self.snapshot.iter_mut().zip(&self.snapshot_of) {
1209 *slot = self.state.get(*index);
1210 }
1211 }
1212
1213 fn restore_snapshot(&mut self) {
1214 for (value, index) in self.snapshot.iter().zip(&self.snapshot_of) {
1215 self.state.set(*index, *value);
1216 }
1217 }
1218}
1219
1220/// Output `i`'s identity value — what the register holds before a program runs,
1221/// so a program that never writes it leaves the past still.
1222fn identity_output(i: usize) -> f32 {
1223 match OUTPUT_NAMES.get(i) {
1224 Some(&"zoom") | Some(&"sx") | Some(&"sy") => 1.0,
1225 Some(&"cx") | Some(&"cy") => 0.5,
1226 _ => 0.0,
1227 }
1228}
1229
1230/// The widest a converted factor may get, and its reciprocal the narrowest.
1231///
1232/// Raising to [`NOMINAL_FPS`] is a thirtieth power, so it **overflows `f32` at a
1233/// per-frame factor of about 13** — and an overflow that fell back to `1.0` would
1234/// turn the most extreme zoom a preset can ask for into no zoom at all, which is
1235/// the opposite of what it says. Saturating instead keeps the direction: a
1236/// runaway zoom collapses the source window to a point, which is what a runaway
1237/// zoom looks like. Wide enough that no plausible preset reaches it (`1.05` per
1238/// frame, a brisk drift, is `4.3` per second).
1239const MAX_FACTOR: f32 = 1.0e30;
1240
1241/// A per-frame survival/scale factor as a per-second one, at [`NOMINAL_FPS`].
1242///
1243/// `v^fps`: thirty frames of `0.96` is `0.96^30` per second at the nominal rate.
1244/// Total on a non-finite or non-positive input, which a program can produce — a
1245/// factor at or below zero is not a factor, so it reads as the identity rather
1246/// than as a mirror.
1247fn per_second_factor(v: f32) -> f32 {
1248 if !v.is_finite() || v <= 0.0 {
1249 return 1.0;
1250 }
1251 let out = v.powf(NOMINAL_FPS);
1252 if out.is_finite() {
1253 out.clamp(1.0 / MAX_FACTOR, MAX_FACTOR)
1254 } else if v > 1.0 {
1255 MAX_FACTOR
1256 } else {
1257 1.0 / MAX_FACTOR
1258 }
1259}
1260
1261/// A per-frame **scale** as a per-second one, with its sign carried through.
1262///
1263/// [`per_second_factor`]'s "at or below zero is not a factor" is right for
1264/// `decay`, which is a survival fraction — but three of the nine per-vertex
1265/// outputs are *scales*, and a NEGATIVE scale is MilkDrop's standard mirror
1266/// idiom (363 corpus files, 3.5 %). Reading one as the identity deleted the
1267/// mirror here, before the mesh vertex stage ever saw the value. That stage's
1268/// own `max()` guard deleted it a second time; both halves are
1269/// design-backlog 0114, and the other half is `warp_mesh`'s `signed_rate`.
1270///
1271/// The magnitude converts exactly as an unsigned factor does, so a positive
1272/// input is bit-identical to [`per_second_factor`] and nothing shipping a
1273/// positive scale moves. Zero stays on the positive arm for the same reason it
1274/// does in the shader: it is not a mirror, and must not become one.
1275fn per_second_signed_factor(v: f32) -> f32 {
1276 if v.is_finite() && v < 0.0 {
1277 -per_second_factor(-v)
1278 } else {
1279 per_second_factor(v)
1280 }
1281}
1282
1283/// The nine raw MilkDrop outputs as this engine's per-second vocabulary.
1284fn convert_outputs(raw: [f32; 9]) -> [f32; 9] {
1285 std::array::from_fn(|i| {
1286 let v = raw.get(i).copied().unwrap_or(0.0);
1287 if OUTPUT_FACTOR.get(i).copied().unwrap_or(false) {
1288 per_second_signed_factor(v)
1289 } else if OUTPUT_RATE.get(i).copied().unwrap_or(false) {
1290 let out = v * NOMINAL_FPS;
1291 if out.is_finite() { out } else { 0.0 }
1292 } else if v.is_finite() {
1293 v
1294 } else {
1295 identity_output(i)
1296 }
1297 })
1298}
1299
1300#[cfg(test)]
1301mod tests;