Skip to main content

rlx_core/milk/
bytecode.rs

1//! The bytecode an EEL2 program compiles to, and its text encoding.
2//!
3//! **The seam between the two halves of Plan 0100.** `milkconv` compiles `.milk`
4//! text into an [`EelProgram`]; `core` executes one. Nothing here parses EEL2 —
5//! the parser is in the converter and never ships (ADR-0113).
6//!
7//! # Why the encoding is text
8//!
9//! A bundle carries a program as **assembly text**, not as packed bytes, and it
10//! is a deliberate trade of a few kilobytes for four properties:
11//!
12//! - **No serialization dependency.** `serde` would have to describe an enum with
13//!   thirty-odd variants into a format `toml` can carry; hand-written `Display` +
14//!   `FromStr` over one op per line costs nothing and adds no crate
15//!   (`lightweight is a feature`).
16//! - **A bundle is diffable.** A converted preset's program shows up in review as
17//!   lines, so a converter change that perturbs codegen is visible rather than a
18//!   changed blob.
19//! - **Round-tripping is a property, not a hope.** [`EelProgram::to_assembly`] and
20//!   [`EelProgram::from_assembly`] are inverses, which is assertable and asserted.
21//! - **A malformed program is a surfaced load error**, like every other preset
22//!   boundary (ADR-0002 / NFR §10) — the decoder validates jump targets and
23//!   register indices once, here, so the VM can trust them.
24//!
25//! # What the VM may assume after decoding
26//!
27//! [`EelProgram::from_assembly`] rejects a program whose jump target is out of
28//! range or whose register index is at or past `n_regs`. Everything downstream —
29//! [`vm::run`](super::vm::run) — indexes on that guarantee, which is what lets the
30//! interpreter loop stay free of bounds checks it would otherwise pay per op per
31//! vertex. **Nothing constructs an `EelProgram` except this decoder and the
32//! converter's codegen**, and the codegen runs its own output through
33//! `EelProgram::validate` before writing it.
34
35// Load-time code, but this module is named in the hygiene guard's scan set
36// (Plan 0100 Phase 2): the VM beside it runs per vertex per frame, and the file
37// convention for anything the guard scans is the panic-denial pragma.
38#![deny(
39    clippy::unwrap_used,
40    clippy::expect_used,
41    clippy::indexing_slicing,
42    clippy::panic,
43    clippy::unreachable
44)]
45
46use std::fmt;
47
48/// A one-argument builtin.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Unary {
51    /// EEL2's `sin` — radians.
52    Sin,
53    /// EEL2's `cos` — radians.
54    Cos,
55    /// EEL2's `tan` — radians.
56    Tan,
57    /// EEL2's `asin`, on an argument clamped into `-1..=1`.
58    Asin,
59    /// EEL2's `acos`, on an argument clamped into `-1..=1`.
60    Acos,
61    /// EEL2's `atan`.
62    Atan,
63    /// EEL2's `sqrt`, `0` on a negative argument.
64    Sqrt,
65    /// `1 / sqrt(x)`, EEL2's `invsqrt`.
66    InvSqrt,
67    /// EEL2's `exp`.
68    Exp,
69    /// Natural log, EEL2's `log`.
70    Log,
71    /// Base-10 logarithm, EEL2's `log10`.
72    Log10,
73    /// EEL2's `abs`.
74    Abs,
75    /// EEL2's three-way `sign`: `-1`, `0` or `1`.
76    Sign,
77    /// `x * x`, EEL2's `sqr`.
78    Sqr,
79    /// EEL2's `floor`.
80    Floor,
81    /// EEL2's `ceil`.
82    Ceil,
83    /// Truncation toward zero, EEL2's `int`.
84    Int,
85    /// EEL2's `bnot`: `1` when the argument is zero, else `0`.
86    BNot,
87    /// `rand(x)` — a salted deterministic draw in `[0, x)` (ADR-0051).
88    Rand,
89    /// `randint(x)` — `floor(rand(x))`.
90    RandInt,
91}
92
93impl Unary {
94    /// The name the assembly text uses, which is also the EEL2 spelling.
95    pub fn as_str(self) -> &'static str {
96        match self {
97            Unary::Sin => "sin",
98            Unary::Cos => "cos",
99            Unary::Tan => "tan",
100            Unary::Asin => "asin",
101            Unary::Acos => "acos",
102            Unary::Atan => "atan",
103            Unary::Sqrt => "sqrt",
104            Unary::InvSqrt => "invsqrt",
105            Unary::Exp => "exp",
106            Unary::Log => "log",
107            Unary::Log10 => "log10",
108            Unary::Abs => "abs",
109            Unary::Sign => "sign",
110            Unary::Sqr => "sqr",
111            Unary::Floor => "floor",
112            Unary::Ceil => "ceil",
113            Unary::Int => "int",
114            Unary::BNot => "bnot",
115            Unary::Rand => "rand",
116            Unary::RandInt => "randint",
117        }
118    }
119
120    /// Parse an assembly/EEL2 name.
121    pub fn from_name(name: &str) -> Option<Self> {
122        Some(match name {
123            "sin" => Unary::Sin,
124            "cos" => Unary::Cos,
125            "tan" => Unary::Tan,
126            "asin" => Unary::Asin,
127            "acos" => Unary::Acos,
128            "atan" => Unary::Atan,
129            "sqrt" => Unary::Sqrt,
130            "invsqrt" => Unary::InvSqrt,
131            "exp" => Unary::Exp,
132            "log" => Unary::Log,
133            "log10" => Unary::Log10,
134            "abs" => Unary::Abs,
135            "sign" => Unary::Sign,
136            "sqr" => Unary::Sqr,
137            "floor" => Unary::Floor,
138            "ceil" => Unary::Ceil,
139            "int" => Unary::Int,
140            "bnot" => Unary::BNot,
141            "rand" => Unary::Rand,
142            "randint" => Unary::RandInt,
143            _ => return None,
144        })
145    }
146
147    /// Whether this builtin reads the VM's RNG rather than only its argument.
148    /// The two that do are the reason a program's execution is a function of
149    /// `(inputs, salt, call sequence)` rather than of the inputs alone.
150    pub fn is_random(self) -> bool {
151        matches!(self, Unary::Rand | Unary::RandInt)
152    }
153}
154
155/// A two-argument builtin.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum Binary {
158    /// EEL2's `min`.
159    Min,
160    /// EEL2's `max`.
161    Max,
162    /// EEL2's `pow`.
163    Pow,
164    /// EEL2's `atan2(y, x)`.
165    Atan2,
166    /// EEL2's `sigmoid(x, c)`.
167    Sigmoid,
168    /// EEL2's `band` — non-lazy logical and. `&&` compiles to jumps instead.
169    BAnd,
170    /// EEL2's `bor` — non-lazy logical or.
171    BOr,
172    /// EEL2's `above(a, b)`.
173    Above,
174    /// EEL2's `below(a, b)`.
175    Below,
176    /// EEL2's `equal(a, b)`.
177    Equal,
178}
179
180impl Binary {
181    /// The name the assembly text uses.
182    pub fn as_str(self) -> &'static str {
183        match self {
184            Binary::Min => "min",
185            Binary::Max => "max",
186            Binary::Pow => "pow",
187            Binary::Atan2 => "atan2",
188            Binary::Sigmoid => "sigmoid",
189            Binary::BAnd => "band",
190            Binary::BOr => "bor",
191            Binary::Above => "above",
192            Binary::Below => "below",
193            Binary::Equal => "equal",
194        }
195    }
196
197    /// Parse an assembly/EEL2 name.
198    pub fn from_name(name: &str) -> Option<Self> {
199        Some(match name {
200            "min" => Binary::Min,
201            "max" => Binary::Max,
202            "pow" => Binary::Pow,
203            "atan2" => Binary::Atan2,
204            "sigmoid" => Binary::Sigmoid,
205            "band" => Binary::BAnd,
206            "bor" => Binary::BOr,
207            "above" => Binary::Above,
208            "below" => Binary::Below,
209            "equal" => Binary::Equal,
210            _ => return None,
211        })
212    }
213}
214
215/// Which scratch arena a memory op addresses.
216///
217/// `megabuf` is the preset's own; `gmegabuf` is shared across the three programs
218/// of one bundle. Both are **fixed arenas allocated once at preset load** —
219/// nothing here grows per frame, because this runs on the render thread
220/// (NFR §5).
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Mem {
223    /// EEL2's `megabuf`.
224    Local,
225    /// EEL2's `gmegabuf`.
226    Global,
227}
228
229impl Mem {
230    /// The name the assembly text uses.
231    pub fn as_str(self) -> &'static str {
232        match self {
233            Mem::Local => "megabuf",
234            Mem::Global => "gmegabuf",
235        }
236    }
237
238    /// Parse an assembly/EEL2 name.
239    pub fn from_name(name: &str) -> Option<Self> {
240        Some(match name {
241            "megabuf" => Mem::Local,
242            "gmegabuf" => Mem::Global,
243            _ => return None,
244        })
245    }
246}
247
248/// One bytecode instruction.
249///
250/// A **stack** machine, not a register one, and deliberately: EEL2 is an
251/// expression language whose statements are expressions, so a stack matches its
252/// shape and the codegen is a post-order walk with no allocation. The named
253/// variables *are* registers ([`Op::Load`] / [`Op::Store`]); the stack is only
254/// the arithmetic's scratch.
255#[derive(Debug, Clone, Copy, PartialEq)]
256pub enum Op {
257    /// Push a literal.
258    Const(f32),
259    /// Push register `n`.
260    Load(u16),
261    /// Pop, store into register `n`, and **push the value back** — EEL2's
262    /// assignment is an expression that yields what it assigned.
263    Store(u16),
264    /// Discard the top of the stack. Emitted between the statements of a
265    /// sequence, whose value is its last statement's.
266    Pop,
267    /// Arithmetic negation.
268    Neg,
269    /// Addition.
270    Add,
271    /// Subtraction.
272    Sub,
273    /// Multiplication.
274    Mul,
275    /// Division. **Total**: a zero divisor yields `0`, which is EEL2's own
276    /// behaviour and what keeps the VM panic-free (Plan 0100 Phase 2).
277    Div,
278    /// Remainder, on the integer parts, EEL2's `%`. Total the same way.
279    Mod,
280    /// EEL2's `^`, which is exponentiation rather than xor.
281    /// EEL2's `pow`.
282    Pow,
283    /// `>` — pushes `1` or `0`.
284    Above,
285    /// `<`.
286    Below,
287    /// `>=`.
288    AboveEq,
289    /// `<=`.
290    BelowEq,
291    /// `==`, against EEL2's comparison epsilon.
292    Equal,
293    /// `!=`, against the same epsilon.
294    NotEqual,
295    /// `!` — `1` when the operand is zero, else `0`.
296    Not,
297    /// EEL2's `&` — **bitwise** and on the truncated integer parts, not a
298    /// logical one. `band` is the logical operator, and the two are different
299    /// functions: `3 & 4` is `0` where `band(3, 4)` is `1`.
300    BitAnd,
301    /// EEL2's `|` — bitwise or, the counterpart of [`Op::BitAnd`].
302    BitOr,
303    /// One-argument builtin.
304    Fn1(Unary),
305    /// Two-argument builtin.
306    Fn2(Binary),
307    /// Pop an index, push that slot.
308    MemLoad(Mem),
309    /// Pop a value and an index (value on top), store, and push the value back.
310    MemStore(Mem),
311    /// Unconditional jump to an absolute instruction index.
312    Jump(u32),
313    /// Pop; jump when the value is zero.
314    JumpIfZero(u32),
315    /// Pop; jump when the value is non-zero.
316    JumpIfNotZero(u32),
317    /// Pop a count, open a loop frame, and jump past [`Op::LoopEnd`] when the
318    /// count rounds to less than one. The count is clamped to
319    /// [`Budget::loops`](super::vm::Budget::loops).
320    LoopBegin(u32),
321    /// Close a loop iteration: discard the body's value, decrement, and jump back
322    /// to the operand while iterations remain. Pushes `0` when the loop ends,
323    /// because a loop is an expression.
324    LoopEnd(u32),
325    /// Open a `while` frame with [`Budget::loops`](super::vm::Budget::loops)
326    /// iterations, jumping past
327    /// its [`Op::WhileEnd`] never — the test is at the end, because EEL2's
328    /// `while(body)` runs the body first.
329    WhileBegin(u32),
330    /// Close a `while` iteration: pop the body's value, decrement, and jump back
331    /// to the operand while it is non-zero and iterations remain. Pushes `0`
332    /// when the loop ends.
333    WhileEnd(u32),
334}
335
336/// EEL2's comparison epsilon: `==` is true within this, and `!=` outside it.
337///
338/// Not a tolerance we chose — it is part of the language, and a preset written
339/// against it (`equal(x, 0)` after arithmetic that lands near zero) reads
340/// differently under an exact comparison.
341pub const COMPARE_EPSILON: f32 = 0.00001;
342
343/// One compiled EEL2 program: flat bytecode over a fixed register file.
344///
345/// Fixed-size at load; nothing here grows per frame.
346#[derive(Debug, Clone, PartialEq)]
347pub struct EelProgram {
348    /// The instructions, in execution order. Jump operands are indices into this.
349    code: Vec<Op>,
350    /// Register names, positionally — `names[i]` is register `i`. The host
351    /// resolves the roster it cares about (`zoom`, `q1`, `bass`, …) to indices
352    /// **once at load** through [`register`](Self::register), so the frame loop
353    /// never looks a name up.
354    names: Vec<String>,
355    /// The deepest the operand stack can get, computed at validation. The VM
356    /// sizes its stack from it, so a running program cannot outgrow one.
357    stack_depth: usize,
358    /// Every register this program can write, ascending and deduplicated.
359    ///
360    /// The per-vertex driver's whole optimization. MilkDrop runs a per-vertex
361    /// program against a **copy** of the per-frame register state, so writes
362    /// inside it do not leak from one vertex to the next; copying the whole file
363    /// per vertex is thousands of floats per frame for the sake of a handful that
364    /// actually move. Snapshotting only these is the same semantics at a fraction
365    /// of the memory traffic, and it is exact rather than an approximation —
366    /// a register no `Store` names cannot change.
367    written: Vec<u16>,
368}
369
370/// What is wrong with a program, as a surfaced load error.
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum ProgramError {
373    /// A jump operand addresses no instruction.
374    BadJump {
375        /// The instruction holding the jump.
376        at: usize,
377        /// The operand.
378        target: u32,
379    },
380    /// A register index is at or past the declared register count.
381    BadRegister {
382        /// The instruction holding it.
383        at: usize,
384        /// The index.
385        index: u16,
386    },
387    /// The program pops more than it pushes somewhere, so the stack would
388    /// underflow.
389    StackUnderflow {
390        /// The instruction that would underflow.
391        at: usize,
392    },
393    /// A `.regs` name appears twice, so [`register`](EelProgram::register) could
394    /// not say which one a host meant.
395    DuplicateRegister(String),
396    /// An assembly line the decoder does not recognize.
397    BadLine {
398        /// 1-based line number in the assembly text.
399        line: usize,
400        /// The offending text.
401        text: String,
402    },
403    /// A `.regs` or `.code` header is missing or out of order.
404    BadHeader(&'static str),
405}
406
407impl fmt::Display for ProgramError {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            ProgramError::BadJump { at, target } => {
411                write!(
412                    f,
413                    "instruction {at} jumps to {target}, which is not an instruction"
414                )
415            }
416            ProgramError::BadRegister { at, index } => {
417                write!(
418                    f,
419                    "instruction {at} addresses register {index}, which is not declared"
420                )
421            }
422            ProgramError::StackUnderflow { at } => {
423                write!(f, "instruction {at} pops from an empty stack")
424            }
425            ProgramError::DuplicateRegister(name) => {
426                write!(f, "register '{name}' is declared twice")
427            }
428            ProgramError::BadLine { line, text } => {
429                write!(f, "line {line}: unrecognized instruction '{text}'")
430            }
431            ProgramError::BadHeader(what) => write!(f, "{what}"),
432        }
433    }
434}
435
436impl std::error::Error for ProgramError {}
437
438impl EelProgram {
439    /// A program with no instructions and no registers — the identity, and what
440    /// an absent section in a bundle means.
441    pub fn empty() -> Self {
442        Self {
443            code: Vec::new(),
444            names: Vec::new(),
445            stack_depth: 0,
446            written: Vec::new(),
447        }
448    }
449
450    /// Assemble from code and register names, validating both.
451    ///
452    /// **The only constructor**, so the invariants `validate`
453    /// establishes hold of every `EelProgram` that exists.
454    pub fn new(code: Vec<Op>, names: Vec<String>) -> Result<Self, ProgramError> {
455        let mut program = Self {
456            code,
457            names,
458            stack_depth: 0,
459            written: Vec::new(),
460        };
461        program.stack_depth = program.validate()?;
462        program.written = program
463            .code
464            .iter()
465            .filter_map(|op| match op {
466                Op::Store(index) => Some(*index),
467                _ => None,
468            })
469            .collect();
470        program.written.sort_unstable();
471        program.written.dedup();
472        Ok(program)
473    }
474
475    /// The instructions.
476    pub fn code(&self) -> &[Op] {
477        &self.code
478    }
479
480    /// The register names, positionally.
481    pub fn names(&self) -> &[String] {
482        &self.names
483    }
484
485    /// How many registers the program declares.
486    pub fn register_count(&self) -> usize {
487        self.names.len()
488    }
489
490    /// The deepest the operand stack gets. The VM sizes its stack from this.
491    pub fn stack_depth(&self) -> usize {
492        self.stack_depth
493    }
494
495    /// Every register this program can write — see the field.
496    pub fn written_registers(&self) -> &[u16] {
497        &self.written
498    }
499
500    /// The index of the register called `name`, or `None`.
501    ///
502    /// **Called at load, never per frame.** The host resolves the whole roster it
503    /// cares about once and then addresses registers by index.
504    pub fn register(&self, name: &str) -> Option<u16> {
505        self.names
506            .iter()
507            .position(|n| n == name)
508            .and_then(|i| u16::try_from(i).ok())
509    }
510
511    /// Whether the program can draw from the RNG, i.e. whether its output depends
512    /// on anything but its inputs and its register file.
513    ///
514    /// Read by the capture harness's determinism argument: a program that says
515    /// `false` here is a pure function of its inputs, and one that says `true` is
516    /// a pure function of its inputs **and the VM's seeded RNG state**, which is
517    /// itself reset with the preset (ADR-0051).
518    pub fn uses_random(&self) -> bool {
519        self.code
520            .iter()
521            .any(|op| matches!(op, Op::Fn1(f) if f.is_random()))
522    }
523
524    /// Check every jump target, every register index, and that the stack never
525    /// underflows; return the deepest the stack gets.
526    ///
527    /// The stack walk is a **linear** pass rather than a flow-sensitive one: it
528    /// takes each instruction's net effect in program order. That is exact for
529    /// the codegen this crate's converter emits — every branch it generates is
530    /// stack-balanced at its join — and conservative nowhere, because an
531    /// unbalanced branch would show up as a mismatch at the join in a
532    /// flow-sensitive walk and as a wrong depth here. The VM does not rely on the
533    /// depth being tight: it is a capacity, and the interpreter guards its own
534    /// pops (returning `0` on an empty stack) so a hand-written program that
535    /// defeats this walk misbehaves rather than panicking.
536    fn validate(&self) -> Result<usize, ProgramError> {
537        let len = self.code.len();
538        for (at, op) in self.code.iter().enumerate() {
539            match *op {
540                Op::Jump(t)
541                | Op::JumpIfZero(t)
542                | Op::JumpIfNotZero(t)
543                | Op::LoopBegin(t)
544                | Op::LoopEnd(t)
545                | Op::WhileBegin(t)
546                | Op::WhileEnd(t) => {
547                    // A jump to exactly `len` is the ordinary "fall off the end"
548                    // target the codegen emits for a branch over the last
549                    // instruction, so it is in range.
550                    if t as usize > len {
551                        return Err(ProgramError::BadJump { at, target: t });
552                    }
553                }
554                Op::Load(index) | Op::Store(index) if index as usize >= self.names.len() => {
555                    return Err(ProgramError::BadRegister { at, index });
556                }
557                _ => {}
558            }
559        }
560        for (i, name) in self.names.iter().enumerate() {
561            if self.names.iter().take(i).any(|n| n == name) {
562                return Err(ProgramError::DuplicateRegister(name.clone()));
563            }
564        }
565
566        let mut depth = 0i64;
567        let mut peak = 0i64;
568        for (at, op) in self.code.iter().enumerate() {
569            let (pops, pushes) = op.stack_effect();
570            depth -= pops as i64;
571            if depth < 0 {
572                return Err(ProgramError::StackUnderflow { at });
573            }
574            depth += pushes as i64;
575            peak = peak.max(depth);
576        }
577        // One slot of headroom, so an interpreter push never has to test the
578        // capacity it was sized from.
579        Ok(peak.max(0) as usize + 1)
580    }
581}
582
583impl Op {
584    /// How many operands this instruction pops and pushes.
585    ///
586    /// The loop ops are the interesting ones: `LoopBegin` pops its count and
587    /// pushes nothing (the counter lives on the VM's separate loop stack), and
588    /// `LoopEnd` pops the body's value and pushes the loop's own result — so a
589    /// loop is net neutral, which is what makes it usable as a sub-expression.
590    fn stack_effect(self) -> (u8, u8) {
591        match self {
592            Op::Const(_) | Op::Load(_) => (0, 1),
593            Op::Store(_) => (1, 1),
594            Op::Pop => (1, 0),
595            Op::Neg | Op::Not | Op::Fn1(_) | Op::MemLoad(_) => (1, 1),
596            Op::Add
597            | Op::Sub
598            | Op::Mul
599            | Op::Div
600            | Op::Mod
601            | Op::Pow
602            | Op::Above
603            | Op::Below
604            | Op::AboveEq
605            | Op::BelowEq
606            | Op::Equal
607            | Op::NotEqual
608            | Op::BitAnd
609            | Op::BitOr
610            | Op::Fn2(_) => (2, 1),
611            Op::MemStore(_) => (2, 1),
612            Op::Jump(_) => (0, 0),
613            Op::JumpIfZero(_) | Op::JumpIfNotZero(_) => (1, 0),
614            Op::LoopBegin(_) | Op::WhileBegin(_) => (1, 0),
615            Op::LoopEnd(_) | Op::WhileEnd(_) => (1, 1),
616        }
617    }
618}
619
620// ---------------------------------------------------------------------------
621// The text encoding
622// ---------------------------------------------------------------------------
623
624impl EelProgram {
625    /// This program as assembly text — the form a bundle carries.
626    ///
627    /// ```text
628    /// .regs zoom rot _t0
629    /// .code
630    /// const 1.5
631    /// load 0
632    /// mul
633    /// store 0
634    /// ```
635    ///
636    /// The inverse of [`from_assembly`](Self::from_assembly), which is asserted
637    /// rather than intended.
638    pub fn to_assembly(&self) -> String {
639        let mut out = String::new();
640        out.push_str(".regs");
641        for name in &self.names {
642            out.push(' ');
643            out.push_str(name);
644        }
645        out.push_str("\n.code\n");
646        for op in &self.code {
647            out.push_str(&op_to_text(*op));
648            out.push('\n');
649        }
650        out
651    }
652
653    /// Decode assembly text, validating it into a program the VM may trust.
654    ///
655    /// Blank lines and `#` comments are ignored, so a bundle stays legible.
656    pub fn from_assembly(text: &str) -> Result<Self, ProgramError> {
657        let mut names: Vec<String> = Vec::new();
658        let mut code: Vec<Op> = Vec::new();
659        let mut seen_regs = false;
660        let mut seen_code = false;
661        for (index, raw) in text.lines().enumerate() {
662            let line = raw.split('#').next().unwrap_or("").trim();
663            if line.is_empty() {
664                continue;
665            }
666            if let Some(rest) = line.strip_prefix(".regs") {
667                if seen_regs {
668                    return Err(ProgramError::BadHeader("a program declares .regs twice"));
669                }
670                seen_regs = true;
671                names = rest.split_whitespace().map(str::to_string).collect();
672                continue;
673            }
674            if line == ".code" {
675                if !seen_regs {
676                    return Err(ProgramError::BadHeader(".code appears before .regs"));
677                }
678                seen_code = true;
679                continue;
680            }
681            if !seen_code {
682                return Err(ProgramError::BadHeader(
683                    "an instruction appears before .code",
684                ));
685            }
686            let op = op_from_text(line).ok_or_else(|| ProgramError::BadLine {
687                line: index + 1,
688                text: line.to_string(),
689            })?;
690            code.push(op);
691        }
692        if !seen_regs || !seen_code {
693            return Err(ProgramError::BadHeader(
694                "a program needs a .regs line and a .code line",
695            ));
696        }
697        Self::new(code, names)
698    }
699}
700
701/// One instruction as a line of assembly.
702fn op_to_text(op: Op) -> String {
703    match op {
704        // `{:?}` on an `f32` round-trips exactly (Rust prints the shortest
705        // representation that parses back to the same bits), which is what makes
706        // the encoding lossless.
707        Op::Const(v) => format!("const {v:?}"),
708        Op::Load(n) => format!("load {n}"),
709        Op::Store(n) => format!("store {n}"),
710        Op::Pop => "pop".into(),
711        Op::Neg => "neg".into(),
712        Op::Not => "not".into(),
713        Op::Add => "add".into(),
714        Op::Sub => "sub".into(),
715        Op::Mul => "mul".into(),
716        Op::Div => "div".into(),
717        Op::Mod => "mod".into(),
718        Op::Pow => "pow".into(),
719        Op::Above => "above".into(),
720        Op::Below => "below".into(),
721        Op::AboveEq => "aboveeq".into(),
722        Op::BelowEq => "beloweq".into(),
723        Op::Equal => "equal".into(),
724        Op::NotEqual => "notequal".into(),
725        Op::BitAnd => "bitand".into(),
726        Op::BitOr => "bitor".into(),
727        Op::Fn1(f) => format!("fn1 {}", f.as_str()),
728        Op::Fn2(f) => format!("fn2 {}", f.as_str()),
729        Op::MemLoad(m) => format!("memload {}", m.as_str()),
730        Op::MemStore(m) => format!("memstore {}", m.as_str()),
731        Op::Jump(t) => format!("jump {t}"),
732        Op::JumpIfZero(t) => format!("jz {t}"),
733        Op::JumpIfNotZero(t) => format!("jnz {t}"),
734        Op::LoopBegin(t) => format!("loopbegin {t}"),
735        Op::LoopEnd(t) => format!("loopend {t}"),
736        Op::WhileBegin(t) => format!("whilebegin {t}"),
737        Op::WhileEnd(t) => format!("whileend {t}"),
738    }
739}
740
741/// One line of assembly as an instruction, or `None` if it is not one.
742fn op_from_text(line: &str) -> Option<Op> {
743    let mut parts = line.split_whitespace();
744    let head = parts.next()?;
745    let arg = parts.next();
746    if parts.next().is_some() {
747        return None;
748    }
749    let index = || arg.and_then(|a| a.parse::<u16>().ok());
750    let target = || arg.and_then(|a| a.parse::<u32>().ok());
751    Some(match (head, arg) {
752        ("const", Some(v)) => Op::Const(v.parse::<f32>().ok()?),
753        ("load", _) => Op::Load(index()?),
754        ("store", _) => Op::Store(index()?),
755        ("pop", None) => Op::Pop,
756        ("neg", None) => Op::Neg,
757        ("not", None) => Op::Not,
758        ("add", None) => Op::Add,
759        ("sub", None) => Op::Sub,
760        ("mul", None) => Op::Mul,
761        ("div", None) => Op::Div,
762        ("mod", None) => Op::Mod,
763        ("pow", None) => Op::Pow,
764        ("above", None) => Op::Above,
765        ("below", None) => Op::Below,
766        ("aboveeq", None) => Op::AboveEq,
767        ("beloweq", None) => Op::BelowEq,
768        ("equal", None) => Op::Equal,
769        ("notequal", None) => Op::NotEqual,
770        ("bitand", None) => Op::BitAnd,
771        ("bitor", None) => Op::BitOr,
772        ("fn1", Some(name)) => Op::Fn1(Unary::from_name(name)?),
773        ("fn2", Some(name)) => Op::Fn2(Binary::from_name(name)?),
774        ("memload", Some(name)) => Op::MemLoad(Mem::from_name(name)?),
775        ("memstore", Some(name)) => Op::MemStore(Mem::from_name(name)?),
776        ("jump", _) => Op::Jump(target()?),
777        ("jz", _) => Op::JumpIfZero(target()?),
778        ("jnz", _) => Op::JumpIfNotZero(target()?),
779        ("loopbegin", _) => Op::LoopBegin(target()?),
780        ("loopend", _) => Op::LoopEnd(target()?),
781        ("whilebegin", _) => Op::WhileBegin(target()?),
782        ("whileend", _) => Op::WhileEnd(target()?),
783        _ => return None,
784    })
785}