Skip to main content

rlx_core/milk/
vm.rs

1//! The stack VM that executes an [`EelProgram`].
2//!
3//! **The only half of the EEL2 machine that ships.** The compiler lives in
4//! `milkconv` and never enters `ritmolux.exe` or `foo_ritmolux.dll` (ADR-0113).
5//!
6//! # The three properties this file exists to keep
7//!
8//! **Total.** No operation panics and no input value can make one. Division by
9//! zero yields `0`, `log(0)` yields `0`, an out-of-range `megabuf` index reads
10//! `0` and writes nowhere, an unbalanced pop reads `0`, and every loop is bounded
11//! by [`Budget::loops`]. `unwrap`/`expect`/`panic`/`indexing` are denied on
12//! this path by the Plan 0002 pragma below, and `core/tests/hygiene.rs` scans
13//! this directory so the denial is enforced rather than intended.
14//!
15//! **Allocation-free per frame.** Everything a run needs — the operand stack, the
16//! loop frames, the register file, the scratch arenas — lives in a [`VmState`]
17//! allocated **once at preset load** and reused. [`run`] borrows it. This executes
18//! on the render thread, per vertex, so NFR §5 governs it.
19//!
20//! **Deterministic.** No clock, and the only randomness is a splitmix stream
21//! seeded from the preset's salt (ADR-0051) and advanced by `rand()` calls. Two
22//! runs of the same program over the same register file and the same VM state are
23//! bit-identical, which is what keeps the capture harness a pure function of its
24//! inputs.
25//!
26//! # What "total" costs, stated
27//!
28//! A total VM cannot report an error, so a program that is wrong renders wrong
29//! rather than refusing. That is the right trade *here* — the alternative is a
30//! preset that can stop a frame — and it is why the errors that can be caught are
31//! caught at the boundary instead: the decoder validates jumps, register indices
32//! and stack balance once at load
33//! ([`EelProgram::from_assembly`](super::bytecode::EelProgram::from_assembly)),
34//! and the converter validates its own codegen before writing a bundle.
35
36// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to core/src/milk by
37// Plan 0100 Phase 2). `run` executes per vertex per frame.
38#![deny(
39    clippy::unwrap_used,
40    clippy::expect_used,
41    clippy::indexing_slicing,
42    clippy::panic,
43    clippy::unreachable
44)]
45
46use super::bytecode::{Binary, COMPARE_EPSILON, EelProgram, Mem, Op, Unary};
47
48/// How many slots a preset's `megabuf` holds.
49///
50/// EEL2's reference `megabuf` is 8 388 608 slots, which at `f32` is 32 MB **per
51/// preset** — by a wide margin the largest memory number Plan 0100 could
52/// introduce, and one that would be allocated whether or not a preset touched a
53/// single slot. The corpus census says only **4 %** of it (435 of 10 347 files)
54/// mentions `megabuf` or `gmegabuf` at all.
55///
56/// So the arena is sized from what the corpus plausibly uses rather than from the
57/// reference: 65 536 slots, 256 KB, allocated once per loaded preset and never
58/// grown. An index outside it reads `0` and writes nowhere — total, like every
59/// other edge here.
60///
61/// **This is a number with a shelf life and a named successor.** Plan 0100
62/// Phase 5's `--report` runs the converter over the corpus and ranks the failure
63/// classes; a preset whose `megabuf` addressing runs past this appears there by
64/// name rather than silently rendering wrong. Raise it from that evidence, not
65/// from a hunch.
66pub const MEGABUF_SLOTS: usize = 65_536;
67
68/// How many slots the bundle-shared `gmegabuf` holds. Smaller than
69/// [`MEGABUF_SLOTS`] because it is genuinely a *shared* scratch — the three
70/// programs of one bundle passing values between frames — rather than a preset's
71/// working set.
72pub const GMEGABUF_SLOTS: usize = 8_192;
73
74/// How deep `loop`/`while` frames may nest.
75///
76/// Four is past anything the corpus census suggests and keeps the frame stack a
77/// fixed array. A `loop` opened past this depth runs its body **once** rather
78/// than looping, which is the total degradation this file applies everywhere.
79pub const MAX_LOOP_DEPTH: usize = 8;
80
81/// A fixed mix-in for the VM's RNG seed, so salt `0` — the salt every preset
82/// that declares no `[generator] seed` gets — is not a degenerate stream, and so
83/// two subsystems seeded from the same salt do not draw the same sequence.
84///
85/// The ASCII of `MILKDR`, which is arbitrary and only has to be non-zero.
86const MILK_SEED_MIX: u64 = 0x4D49_4C4B_4452;
87
88/// What one [`run`] may spend: how many iterations any single loop may take, and
89/// how many instructions the whole run may execute.
90///
91/// **Per program rather than one constant, because the three programs of a bundle
92/// cost wildly different amounts.** `per_frame_init` runs once at load;
93/// `per_frame` runs once a frame; `per_vertex` runs once per *vertex*, thousands
94/// of times a frame. A single bound tight enough for the third would break the
95/// first — the corpus's commonest `per_frame_init` idiom is
96/// `loop(10000, megabuf(index) = .1; index = index + 1)`, seeding a scratch
97/// array, and 84 presets in the largest pack do exactly that.
98///
99/// Both numbers are bounds, not budgets: nothing is reserved and an honest
100/// program never approaches either. What they buy is that **untrusted program
101/// text cannot hang a frame**, which is ADR-0113's stated residual risk on the
102/// shader side and is closed on this side.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct Budget {
105    /// The most iterations any one `loop()` or `while()` may run.
106    pub loops: u32,
107    /// The most instructions the whole run may execute — the backstop under the
108    /// loop bound, so a program whose bare jumps form a cycle still terminates.
109    pub instructions: u32,
110}
111
112impl Budget {
113    /// `per_frame_init`: runs **once**, at preset load, off the hot path. The
114    /// loop bound is MilkDrop's own (`1 << 20`), and the instruction bound is
115    /// generous enough that seeding a whole `megabuf` fits.
116    pub const INIT: Self = Self {
117        loops: 1_048_576,
118        instructions: 16_000_000,
119    };
120    /// `per_frame`: once a frame. A million instructions is a few milliseconds
121    /// on the CPUs this ships to — far past any real preset, and far under a
122    /// stall.
123    pub const FRAME: Self = Self {
124        loops: 1_048_576,
125        instructions: 1_000_000,
126    };
127    /// `per_vertex`: once per **vertex**, so its bound is multiplied by the mesh.
128    /// At the rich tier's 5 963 vertices this ceiling is still 49 M instructions
129    /// a frame in the worst case, which is why it is three orders under the
130    /// others — real per-vertex programs are a few hundred instructions, and the
131    /// measured tier ladder (`TierConfig::mesh_grid`) is priced against that.
132    pub const VERTEX: Self = Self {
133        loops: 1_024,
134        instructions: 8_192,
135    };
136}
137
138/// One open loop, on the VM's own frame stack.
139#[derive(Clone, Copy)]
140struct LoopFrame {
141    /// Iterations left after the current one.
142    remaining: u32,
143}
144
145/// Everything a run needs beyond the program: the register file, the scratch
146/// arenas, the operand stack and the RNG.
147///
148/// **Allocated once at preset load.** A frame borrows it mutably and returns it
149/// unchanged in shape — nothing here resizes while a preset renders, which is the
150/// whole of the real-time claim.
151pub struct VmState {
152    /// The named variables, positionally by the program's `.regs` order.
153    registers: Vec<f32>,
154    /// The preset's own `megabuf`.
155    megabuf: Vec<f32>,
156    /// The bundle-shared `gmegabuf`.
157    gmegabuf: Vec<f32>,
158    /// The operand stack, sized from the program's validated depth.
159    stack: Vec<f32>,
160    /// Open `loop`/`while` frames.
161    frames: [LoopFrame; MAX_LOOP_DEPTH],
162    depth: usize,
163    /// The splitmix stream `rand()` draws from (ADR-0051). Seeded from the
164    /// preset's salt at load and advanced per call, so a run is reproducible and
165    /// two presets writing the same expression scatter differently.
166    rng: u64,
167    /// The seed, kept so [`reset_rng`](Self::reset_rng) can restore it — a
168    /// capture re-runs a preset from the top and must draw the same sequence.
169    seed: u64,
170}
171
172impl VmState {
173    /// Allocate for a program of `registers` registers and `stack_depth` operand
174    /// slots, with the RNG seeded from `salt`.
175    ///
176    /// Sized for the **largest** of a bundle's programs by the caller, so the
177    /// three of them share one state and the values a per-frame program leaves in
178    /// its registers are what the per-vertex program starts from — which is
179    /// MilkDrop's own execution model.
180    pub fn new(registers: usize, stack_depth: usize, salt: u32) -> Self {
181        Self {
182            registers: vec![0.0; registers],
183            megabuf: vec![0.0; MEGABUF_SLOTS],
184            gmegabuf: vec![0.0; GMEGABUF_SLOTS],
185            stack: vec![0.0; stack_depth.max(1)],
186            frames: [LoopFrame { remaining: 0 }; MAX_LOOP_DEPTH],
187            depth: 0,
188            // Mixed rather than used raw, so salt 0 — the salt a preset that
189            // declares no seed gets — is not a degenerate stream.
190            rng: splitmix(u64::from(salt) ^ MILK_SEED_MIX),
191            seed: u64::from(salt) ^ MILK_SEED_MIX,
192        }
193    }
194
195    /// Grow the register file and operand stack to hold `program`, if they do not
196    /// already. **Load-time only** — called when a bundle's programs are attached,
197    /// never per frame.
198    pub fn accommodate(&mut self, program: &EelProgram) {
199        if self.registers.len() < program.register_count() {
200            self.registers.resize(program.register_count(), 0.0);
201        }
202        if self.stack.len() < program.stack_depth() {
203            self.stack.resize(program.stack_depth(), 0.0);
204        }
205    }
206
207    /// Read register `index`, or `0` for one that does not exist.
208    pub fn get(&self, index: u16) -> f32 {
209        self.registers.get(index as usize).copied().unwrap_or(0.0)
210    }
211
212    /// Write register `index`, ignoring one that does not exist.
213    pub fn set(&mut self, index: u16, value: f32) {
214        if let Some(slot) = self.registers.get_mut(index as usize) {
215            *slot = value;
216        }
217    }
218
219    /// Zero every register — what a preset switch does before the first
220    /// `per_frame_init` run.
221    pub fn clear_registers(&mut self) {
222        self.registers.fill(0.0);
223    }
224
225    /// Zero both scratch arenas. Load-time: a preset must not inherit the
226    /// previous one's `megabuf`.
227    pub fn clear_memory(&mut self) {
228        self.megabuf.fill(0.0);
229        self.gmegabuf.fill(0.0);
230    }
231
232    /// Restore the RNG to its seed, so a re-run of a preset from frame zero draws
233    /// the same sequence (ADR-0051 / NFR §6).
234    pub fn reset_rng(&mut self) {
235        self.rng = splitmix(self.seed);
236    }
237
238    /// The next draw in `[0, 1)`.
239    fn next_unit(&mut self) -> f32 {
240        self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
241        let h = splitmix(self.rng);
242        // The top 24 bits as a unit fraction — the same construction
243        // `gpu::HASH_WGSL`'s `unit01` uses, so CPU and shader randomness read the
244        // same way even though they are separate streams.
245        (h >> 40) as f32 / (1u64 << 24) as f32
246    }
247}
248
249/// One round of splitmix64. Deterministic, dependency-free, and the same mixer
250/// `scenes::SeededRng` uses — one hash in this crate, not two.
251fn splitmix(mut z: u64) -> u64 {
252    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
253    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
254    z ^ (z >> 31)
255}
256
257/// Execute `program` against `state`, returning the value it leaves on top.
258///
259/// Total on every input: see the module docs. The register file, the arenas and
260/// the RNG are `state`'s and persist across calls, which is what lets a
261/// per-frame program set up values a per-vertex program reads.
262pub fn run(program: &EelProgram, state: &mut VmState, budget: Budget) -> f32 {
263    let code = program.code();
264    // A run starts with an empty stack and no open loops. Both are properties of
265    // the *call*, not of the state, so a program that left something behind
266    // cannot poison the next one.
267    let mut sp = 0usize;
268    state.depth = 0;
269    let mut pc = 0usize;
270    let mut fuel = budget.instructions;
271
272    while let Some(&op) = code.get(pc) {
273        if fuel == 0 {
274            break;
275        }
276        fuel -= 1;
277        pc += 1;
278        match op {
279            Op::Const(v) => push(state, &mut sp, v),
280            Op::Load(index) => {
281                let v = state.get(index);
282                push(state, &mut sp, v);
283            }
284            Op::Store(index) => {
285                // Assignment is an expression: the value stays on the stack.
286                let v = peek(state, sp);
287                state.set(index, v);
288            }
289            Op::Pop => {
290                pop(state, &mut sp);
291            }
292            Op::Neg => unary(state, &mut sp, |a| -a),
293            Op::Not => unary(state, &mut sp, |a| f32::from(a == 0.0)),
294            Op::Add => binary(state, &mut sp, |a, b| a + b),
295            Op::Sub => binary(state, &mut sp, |a, b| a - b),
296            Op::Mul => binary(state, &mut sp, |a, b| a * b),
297            // EEL2 yields 0 on a zero divisor rather than an infinity, and the
298            // non-finite guard is ours: a NaN here would poison a register for
299            // the rest of the preset's run.
300            Op::Div => binary(state, &mut sp, |a, b| {
301                finite(if b == 0.0 { 0.0 } else { a / b })
302            }),
303            Op::Mod => binary(state, &mut sp, |a, b| {
304                let d = b.trunc();
305                if d == 0.0 { 0.0 } else { finite(a.trunc() % d) }
306            }),
307            Op::Pow => binary(state, &mut sp, |a, b| finite(a.powf(b))),
308            Op::Above => binary(state, &mut sp, |a, b| f32::from(a > b)),
309            Op::Below => binary(state, &mut sp, |a, b| f32::from(a < b)),
310            Op::AboveEq => binary(state, &mut sp, |a, b| f32::from(a >= b)),
311            Op::BelowEq => binary(state, &mut sp, |a, b| f32::from(a <= b)),
312            Op::Equal => binary(state, &mut sp, |a, b| {
313                f32::from((a - b).abs() < COMPARE_EPSILON)
314            }),
315            Op::NotEqual => binary(state, &mut sp, |a, b| {
316                f32::from((a - b).abs() >= COMPARE_EPSILON)
317            }),
318            // Bitwise on the truncated integer parts, in EEL2's own 32-bit
319            // domain. A value outside `i32` saturates rather than wrapping —
320            // `as` on a float does that in Rust, and it is the total answer.
321            Op::BitAnd => binary(state, &mut sp, |a, b| ((a as i32) & (b as i32)) as f32),
322            Op::BitOr => binary(state, &mut sp, |a, b| ((a as i32) | (b as i32)) as f32),
323            Op::Fn1(f) => {
324                let a = pop(state, &mut sp);
325                let v = apply_unary(f, a, state);
326                push(state, &mut sp, v);
327            }
328            Op::Fn2(f) => {
329                let b = pop(state, &mut sp);
330                let a = pop(state, &mut sp);
331                push(state, &mut sp, apply_binary(f, a, b));
332            }
333            Op::MemLoad(which) => {
334                let index = pop(state, &mut sp);
335                let v = mem_read(state, which, index);
336                push(state, &mut sp, v);
337            }
338            Op::MemStore(which) => {
339                let value = pop(state, &mut sp);
340                let index = pop(state, &mut sp);
341                mem_write(state, which, index, value);
342                // Like `Store`, an assignment yields what it assigned.
343                push(state, &mut sp, value);
344            }
345            Op::Jump(t) => pc = t as usize,
346            Op::JumpIfZero(t) => {
347                if pop(state, &mut sp) == 0.0 {
348                    pc = t as usize;
349                }
350            }
351            Op::JumpIfNotZero(t) => {
352                if pop(state, &mut sp) != 0.0 {
353                    pc = t as usize;
354                }
355            }
356            Op::LoopBegin(end) => {
357                let count = pop(state, &mut sp);
358                // Non-finite or under one means no iterations at all. The clamp is
359                // what makes an untrusted program unable to hang a frame.
360                let iterations = if count.is_finite() && count >= 1.0 {
361                    (count as u32).min(budget.loops)
362                } else {
363                    0
364                };
365                if iterations == 0 || !open_frame(state, iterations) {
366                    // A loop that runs no body is still an expression.
367                    push(state, &mut sp, 0.0);
368                    pc = end as usize;
369                }
370            }
371            Op::LoopEnd(start) => {
372                // The body's value is discarded; the loop's own value is 0.
373                pop(state, &mut sp);
374                if step_frame(state) {
375                    pc = start as usize;
376                } else {
377                    close_frame(state);
378                    push(state, &mut sp, 0.0);
379                }
380            }
381            Op::WhileBegin(end) => {
382                // `while(body)` runs the body first, so the operand its codegen
383                // pushed is only the iteration bound.
384                pop(state, &mut sp);
385                if !open_frame(state, budget.loops) {
386                    push(state, &mut sp, 0.0);
387                    pc = end as usize;
388                }
389            }
390            Op::WhileEnd(start) => {
391                let value = pop(state, &mut sp);
392                if value != 0.0 && step_frame(state) {
393                    pc = start as usize;
394                } else {
395                    close_frame(state);
396                    push(state, &mut sp, 0.0);
397                }
398            }
399        }
400    }
401    if sp == 0 { 0.0 } else { peek(state, sp) }
402}
403
404/// Coerce a non-finite result to `0`.
405///
406/// **Not cosmetic.** A `NaN` written into a register survives every comparison
407/// (`NaN > x` is false, `NaN == x` is false), so a single one poisons a
408/// per-frame variable for the rest of the preset's run and the picture never
409/// comes back. The same reasoning `Easing::step` carries for the smoother.
410fn finite(v: f32) -> f32 {
411    if v.is_finite() { v } else { 0.0 }
412}
413
414fn push(state: &mut VmState, sp: &mut usize, value: f32) {
415    if let Some(slot) = state.stack.get_mut(*sp) {
416        *slot = value;
417        *sp += 1;
418    }
419    // A full stack drops the push rather than growing: the depth came from the
420    // decoder's validation, so this is unreachable for a validated program and
421    // must not allocate for a hand-written one.
422}
423
424fn pop(state: &VmState, sp: &mut usize) -> f32 {
425    match sp.checked_sub(1) {
426        Some(next) => {
427            *sp = next;
428            state.stack.get(next).copied().unwrap_or(0.0)
429        }
430        None => 0.0,
431    }
432}
433
434fn peek(state: &VmState, sp: usize) -> f32 {
435    sp.checked_sub(1)
436        .and_then(|i| state.stack.get(i).copied())
437        .unwrap_or(0.0)
438}
439
440fn unary(state: &mut VmState, sp: &mut usize, f: impl Fn(f32) -> f32) {
441    let a = pop(state, sp);
442    push(state, sp, finite(f(a)));
443}
444
445fn binary(state: &mut VmState, sp: &mut usize, f: impl Fn(f32, f32) -> f32) {
446    let b = pop(state, sp);
447    let a = pop(state, sp);
448    push(state, sp, f(a, b));
449}
450
451/// Open a loop frame, or report that the nesting limit is reached.
452fn open_frame(state: &mut VmState, iterations: u32) -> bool {
453    if state.depth >= MAX_LOOP_DEPTH {
454        return false;
455    }
456    if let Some(frame) = state.frames.get_mut(state.depth) {
457        frame.remaining = iterations.saturating_sub(1);
458        state.depth += 1;
459        return true;
460    }
461    false
462}
463
464/// Consume one iteration of the innermost frame; `true` to go round again.
465fn step_frame(state: &mut VmState) -> bool {
466    let Some(index) = state.depth.checked_sub(1) else {
467        return false;
468    };
469    match state.frames.get_mut(index) {
470        Some(frame) if frame.remaining > 0 => {
471            frame.remaining -= 1;
472            true
473        }
474        _ => false,
475    }
476}
477
478fn close_frame(state: &mut VmState) {
479    state.depth = state.depth.saturating_sub(1);
480}
481
482fn apply_unary(f: Unary, a: f32, state: &mut VmState) -> f32 {
483    let v = match f {
484        Unary::Sin => a.sin(),
485        Unary::Cos => a.cos(),
486        Unary::Tan => a.tan(),
487        Unary::Asin => a.clamp(-1.0, 1.0).asin(),
488        Unary::Acos => a.clamp(-1.0, 1.0).acos(),
489        Unary::Atan => a.atan(),
490        // A negative argument is `0` rather than a NaN — total, and the value a
491        // preset squaring a difference and taking its root expects at zero.
492        Unary::Sqrt => a.max(0.0).sqrt(),
493        Unary::InvSqrt => {
494            let r = a.max(0.0).sqrt();
495            if r == 0.0 { 0.0 } else { 1.0 / r }
496        }
497        Unary::Exp => a.exp(),
498        Unary::Log => {
499            if a > 0.0 {
500                a.ln()
501            } else {
502                0.0
503            }
504        }
505        Unary::Log10 => {
506            if a > 0.0 {
507                a.log10()
508            } else {
509                0.0
510            }
511        }
512        Unary::Abs => a.abs(),
513        // EEL2's `sign` is a three-way: -1, 0 or 1. `f32::signum` says 1.0 for
514        // +0.0, which is a different function.
515        Unary::Sign => {
516            if a > 0.0 {
517                1.0
518            } else if a < 0.0 {
519                -1.0
520            } else {
521                0.0
522            }
523        }
524        Unary::Sqr => a * a,
525        Unary::Floor => a.floor(),
526        Unary::Ceil => a.ceil(),
527        Unary::Int => a.trunc(),
528        Unary::BNot => f32::from(a == 0.0),
529        Unary::Rand => state.next_unit() * a,
530        Unary::RandInt => (state.next_unit() * a).floor(),
531    };
532    finite(v)
533}
534
535fn apply_binary(f: Binary, a: f32, b: f32) -> f32 {
536    let v = match f {
537        // `f32::min`/`max` return the non-NaN operand, which is the total
538        // behaviour wanted here.
539        Binary::Min => a.min(b),
540        Binary::Max => a.max(b),
541        Binary::Pow => a.powf(b),
542        Binary::Atan2 => a.atan2(b),
543        // EEL2's sigmoid: `1 / (1 + exp(-x * c))`, with `c = 0` degenerating to
544        // the midpoint rather than to a division by zero.
545        Binary::Sigmoid => {
546            let t = 1.0 + (-a * b).exp();
547            if t == 0.0 { 0.0 } else { 1.0 / t }
548        }
549        Binary::BAnd => f32::from(a != 0.0 && b != 0.0),
550        Binary::BOr => f32::from(a != 0.0 || b != 0.0),
551        Binary::Above => f32::from(a > b),
552        Binary::Below => f32::from(a < b),
553        Binary::Equal => f32::from((a - b).abs() < COMPARE_EPSILON),
554    };
555    finite(v)
556}
557
558/// The slot `index` addresses, or `None` when it is outside the arena.
559///
560/// EEL2 indexes `megabuf` by a float; the truncation is the language's, and the
561/// range check is ours.
562fn slot(index: f32, len: usize) -> Option<usize> {
563    if !index.is_finite() || index < 0.0 {
564        return None;
565    }
566    let i = index as usize;
567    (i < len).then_some(i)
568}
569
570fn mem_read(state: &VmState, which: Mem, index: f32) -> f32 {
571    let arena = match which {
572        Mem::Local => &state.megabuf,
573        Mem::Global => &state.gmegabuf,
574    };
575    slot(index, arena.len())
576        .and_then(|i| arena.get(i).copied())
577        .unwrap_or(0.0)
578}
579
580fn mem_write(state: &mut VmState, which: Mem, index: f32, value: f32) {
581    let arena = match which {
582        Mem::Local => &mut state.megabuf,
583        Mem::Global => &mut state.gmegabuf,
584    };
585    if let Some(cell) = slot(index, arena.len()).and_then(|i| arena.get_mut(i)) {
586        *cell = finite(value);
587    }
588}