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}