onedrop_eval/
context.rs

1//! Execution context for Milkdrop expressions.
2
3use evalexpr::{
4    Context, ContextWithMutableVariables, DefaultNumericTypes, EvalexprError, EvalexprResult,
5    HashMapContext, Value, error::EvalexprResultValue,
6};
7
8// --- Hot-slot index constants ------------------------------------------------
9//
10// Per-point / per-vertex / per-shape scratch slots stored as an indexed
11// array of `Value` so:
12//   - the bytecode VM resolves each name → slot index at compile time
13//     and does a single array load/store at run time (no string match),
14//   - evalexpr's `Context::get_value(&str)` still finds them by routing
15//     through [`hot_index_of`] → array index in one match.
16//
17// Adding a new hot slot is a 3-step change: bump `HOT_COUNT`, give the
18// constant its index, and add the name → const arm in [`hot_index_of`]
19// plus a sensible default in [`HotVars::default`].
20
21pub const HOT_SAMPLE: usize = 0;
22pub const HOT_VALUE1: usize = 1;
23pub const HOT_VALUE2: usize = 2;
24pub const HOT_X: usize = 3;
25pub const HOT_Y: usize = 4;
26pub const HOT_RAD: usize = 5;
27pub const HOT_ANG: usize = 6;
28pub const HOT_R: usize = 7;
29pub const HOT_G: usize = 8;
30pub const HOT_B: usize = 9;
31pub const HOT_A: usize = 10;
32pub const HOT_INSTANCE: usize = 11;
33pub const HOT_NUM_INST: usize = 12;
34pub const HOT_SIDES: usize = 13;
35pub const HOT_TEX_ZOOM: usize = 14;
36pub const HOT_TEX_ANG: usize = 15;
37pub const HOT_R2: usize = 16;
38pub const HOT_G2: usize = 17;
39pub const HOT_B2: usize = 18;
40pub const HOT_A2: usize = 19;
41pub const HOT_BORDER_R: usize = 20;
42pub const HOT_BORDER_G: usize = 21;
43pub const HOT_BORDER_B: usize = 22;
44pub const HOT_BORDER_A: usize = 23;
45pub const HOT_THICK: usize = 24;
46pub const HOT_ADDITIVE: usize = 25;
47pub const HOT_COUNT: usize = 26;
48
49/// Map a Milkdrop hot-var name → its slot index. Used by the bytecode
50/// compiler (`onedrop-eval::bytecode`) and by evalexpr's `Context` impl
51/// below to short-circuit reads/writes of the hot vars without a HashMap
52/// probe or `String` allocation.
53#[inline]
54pub fn hot_index_of(name: &str) -> Option<usize> {
55    Some(match name {
56        "sample" => HOT_SAMPLE,
57        "value1" => HOT_VALUE1,
58        "value2" => HOT_VALUE2,
59        "x" => HOT_X,
60        "y" => HOT_Y,
61        "rad" => HOT_RAD,
62        "ang" => HOT_ANG,
63        "r" => HOT_R,
64        "g" => HOT_G,
65        "b" => HOT_B,
66        "a" => HOT_A,
67        "instance" => HOT_INSTANCE,
68        "num_inst" => HOT_NUM_INST,
69        "sides" => HOT_SIDES,
70        "tex_zoom" => HOT_TEX_ZOOM,
71        "tex_ang" => HOT_TEX_ANG,
72        "r2" => HOT_R2,
73        "g2" => HOT_G2,
74        "b2" => HOT_B2,
75        "a2" => HOT_A2,
76        "border_r" => HOT_BORDER_R,
77        "border_g" => HOT_BORDER_G,
78        "border_b" => HOT_BORDER_B,
79        "border_a" => HOT_BORDER_A,
80        "thick" => HOT_THICK,
81        "additive" => HOT_ADDITIVE,
82        _ => return None,
83    })
84}
85
86/// Array-backed scratch slots. The bytecode VM addresses these by
87/// index (one `f64` array load per access — no enum-tag match). The
88/// parallel [`HotVars::slots_value`] array backs evalexpr's
89/// `Context::get_value(name) -> Option<&Value>` contract and is kept
90/// in sync with the f64 array on every write.
91#[derive(Debug, Clone)]
92struct HotVars {
93    slots: [f64; HOT_COUNT],
94    /// Mirror of `slots` as `Value`. Only read on the (rare) cold path
95    /// when evalexpr's `Context::get_value` resolves a hot identifier.
96    slots_value: [Value; HOT_COUNT],
97}
98
99impl Default for HotVars {
100    fn default() -> Self {
101        let mut slots = [0.0f64; HOT_COUNT];
102        slots[HOT_X] = 0.5;
103        slots[HOT_Y] = 0.5;
104        slots[HOT_R] = 1.0;
105        slots[HOT_G] = 1.0;
106        slots[HOT_B] = 1.0;
107        slots[HOT_A] = 1.0;
108        slots[HOT_NUM_INST] = 1.0;
109        slots[HOT_SIDES] = 4.0;
110        slots[HOT_TEX_ZOOM] = 1.0;
111        slots[HOT_BORDER_R] = 1.0;
112        slots[HOT_BORDER_G] = 1.0;
113        slots[HOT_BORDER_B] = 1.0;
114        let slots_value: [Value; HOT_COUNT] = std::array::from_fn(|i| Value::Float(slots[i]));
115        Self { slots, slots_value }
116    }
117}
118
119impl HotVars {
120    #[inline]
121    fn slot(&self, name: &str) -> Option<&Value> {
122        hot_index_of(name).map(|idx| &self.slots_value[idx])
123    }
124
125    /// Set a hot slot by name. Returns `false` when `name` isn't a hot
126    /// variable. On a successful set, both the f64 array (VM fast
127    /// path) and the Value mirror (evalexpr API contract) are updated.
128    #[inline]
129    fn set_by_name(&mut self, name: &str, value: f64) -> bool {
130        match hot_index_of(name) {
131            Some(idx) => {
132                self.slots[idx] = value;
133                self.slots_value[idx] = Value::Float(value);
134                true
135            }
136            None => false,
137        }
138    }
139}
140
141/// Pre-baked `"q1".."q32"` strings. Hot paths that need to refer to a
142/// q-channel by *name* (e.g. error messages, evalexpr trait methods that
143/// only take `&str`) can index into this table instead of paying a
144/// `format!("q{}", i)` `String` allocation per call. Reads/writes of
145/// q-channel values themselves should go through [`MilkContext::q_get_idx`]
146/// / [`MilkContext::q_set_idx`] which skip name lookup entirely.
147pub const Q_CHANNEL_NAMES_32: [&str; 32] = [
148    "q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8", "q9", "q10", "q11", "q12", "q13", "q14", "q15",
149    "q16", "q17", "q18", "q19", "q20", "q21", "q22", "q23", "q24", "q25", "q26", "q27", "q28",
150    "q29", "q30", "q31", "q32",
151];
152
153/// Map a `qN` identifier (1 ≤ N ≤ 64) to its 0-based slot index in the
154/// q-vars array. Returns `None` for any non-`qN` name or out-of-range N.
155/// Used by the bytecode VM compiler and by [`MilkContext`]'s `Context`
156/// impl to bypass HashMap probes for the q-channel reads/writes that
157/// dominate per_frame state passing.
158#[inline]
159pub fn q_index_of(name: &str) -> Option<usize> {
160    let bytes = name.as_bytes();
161    if bytes.first()? != &b'q' || bytes.len() < 2 {
162        return None;
163    }
164    let idx: usize = name[1..].parse().ok()?;
165    if (1..=64).contains(&idx) {
166        Some(idx - 1)
167    } else {
168        None
169    }
170}
171
172/// Returns `true` iff `name` is a hot-path scratch variable (per-point,
173/// per-vertex, or per-shape) routed through [`HotVars`] rather than the
174/// underlying `HashMapContext`. The parallel-samples analyser
175/// (`onedrop-engine::wave_phase::carry`) uses the same set.
176pub fn is_hot_var(name: &str) -> bool {
177    matches!(
178        name,
179        "sample"
180            | "value1"
181            | "value2"
182            | "x"
183            | "y"
184            | "rad"
185            | "ang"
186            | "r"
187            | "g"
188            | "b"
189            | "a"
190            | "instance"
191            | "num_inst"
192            | "sides"
193            | "tex_zoom"
194            | "tex_ang"
195            | "r2"
196            | "g2"
197            | "b2"
198            | "a2"
199            | "border_r"
200            | "border_g"
201            | "border_b"
202            | "border_a"
203            | "thick"
204            | "additive"
205    )
206}
207
208/// Indexed slab for cold variables. The bytecode VM resolves cold
209/// names to slab indices at compile time and addresses them directly,
210/// skipping the `HashMapContext` probe + the `String::from` allocation
211/// that [`MilkContext::set`] would otherwise pay on every store.
212///
213/// Two parallel arrays are kept in sync:
214///
215/// - `values`: `Vec<f64>` — the VM's fast path. Reads and writes happen
216///   here without going through the `Value` enum.
217/// - `values_mirror`: `Vec<Value>` — used to back evalexpr's
218///   `Context::get_value(name) -> Option<&Value>` contract, which
219///   requires handing out a reference to a `Value` we own.
220///
221/// `name_to_idx` resolves names on first reference (compile-time for
222/// VM-resolved blocks, runtime for evalexpr-driven `set_value` calls).
223/// Once interned, a name's slot index is stable for the life of this
224/// [`MilkContext`].
225#[derive(Debug, Clone, Default)]
226struct ColdSlab {
227    values: Vec<f64>,
228    values_mirror: Vec<Value>,
229    name_to_idx: std::collections::HashMap<String, usize>,
230}
231
232impl ColdSlab {
233    /// Return the slab index for `name`, allocating a fresh slot
234    /// (initialised to `0.0`) on first reference.
235    fn intern(&mut self, name: &str) -> usize {
236        if let Some(&idx) = self.name_to_idx.get(name) {
237            return idx;
238        }
239        let idx = self.values.len();
240        self.values.push(0.0);
241        self.values_mirror.push(Value::Float(0.0));
242        self.name_to_idx.insert(name.to_string(), idx);
243        idx
244    }
245
246    #[inline]
247    fn get_idx(&self, idx: usize) -> f64 {
248        self.values[idx]
249    }
250
251    #[inline]
252    fn set_idx(&mut self, idx: usize, val: f64) {
253        self.values[idx] = val;
254        self.values_mirror[idx] = Value::Float(val);
255    }
256
257    fn get_by_name(&self, name: &str) -> Option<f64> {
258        self.name_to_idx.get(name).map(|&i| self.values[i])
259    }
260
261    fn get_value_by_name(&self, name: &str) -> Option<&Value> {
262        self.name_to_idx.get(name).map(|&i| &self.values_mirror[i])
263    }
264
265    fn set_by_name(&mut self, name: &str, val: f64) {
266        let idx = self.intern(name);
267        self.set_idx(idx, val);
268    }
269
270    fn set_value_by_name(&mut self, name: &str, value: Value) {
271        let idx = self.intern(name);
272        let f = match &value {
273            Value::Float(f) => *f,
274            Value::Int(i) => *i as f64,
275            Value::Boolean(b) => {
276                if *b {
277                    1.0
278                } else {
279                    0.0
280                }
281            }
282            _ => 0.0,
283        };
284        self.values[idx] = f;
285        self.values_mirror[idx] = value;
286    }
287}
288
289/// Execution context containing all Milkdrop variables.
290#[derive(Debug, Clone)]
291pub struct MilkContext {
292    /// Math function registry (sin, cos, gmegabuf, …). Cold variables
293    /// no longer live here — they're in [`ColdSlab`] for index-based
294    /// access from the bytecode VM.
295    context: HashMapContext,
296
297    /// Hot per-point / per-vertex / per-shape scratch slots — see [`HotVars`].
298    hot: HotVars,
299
300    /// q1..q64 channel storage. `q_vars` is the f64 fast path the
301    /// bytecode VM addresses by index; `q_values_mirror` is kept in
302    /// sync so evalexpr's `Context::get_value` can return `&Value`.
303    q_vars: [f64; 64],
304    q_values_mirror: [Value; 64],
305
306    /// Index-based slab for cold variables — used by the bytecode VM
307    /// (resolved at compile time) and by the evalexpr API (resolved on
308    /// every `get_value`/`set_value` call).
309    cold: ColdSlab,
310}
311
312impl MilkContext {
313    /// Create a new context with default values.
314    pub fn new() -> Self {
315        let mut context = HashMapContext::new();
316
317        // Register math functions on the underlying HashMapContext. Variable
318        // storage has moved to `cold` — the HashMapContext now holds nothing
319        // but functions.
320        crate::math_functions::register_math_functions(&mut context);
321
322        // Clear the thread-local gmegabuf/megabuf scratch buffer so this
323        // evaluator starts with the documented MD2 initial state (all slots
324        // 0.0). Sequential evaluators on the same thread share the buffer;
325        // resetting at construction is the cheap way to guarantee isolation.
326        crate::math_functions::gmegabuf::reset();
327
328        let mut ctx = Self {
329            context,
330            hot: HotVars::default(),
331            q_vars: [0.0f64; 64],
332            q_values_mirror: std::array::from_fn(|_| Value::Float(0.0)),
333            cold: ColdSlab::default(),
334        };
335        // Pre-seed the cold slab with the same defaults `init_defaults`
336        // put into the `HashMapContext`. The slab is the source of truth
337        // going forward; the HashMapContext only holds math functions.
338        Self::seed_cold_defaults(&mut ctx.cold);
339        ctx
340    }
341
342    /// Intern a cold-variable name into the slab and return its index.
343    /// Called by the bytecode compiler so `Op::LoadCold(idx)` /
344    /// `Op::StoreCold(idx)` can address the slot directly at run time.
345    pub fn cold_intern(&mut self, name: &str) -> usize {
346        self.cold.intern(name)
347    }
348
349    /// Bytecode-VM fast path: read a cold-variable slot by index.
350    #[inline]
351    pub fn cold_get_idx(&self, idx: usize) -> f64 {
352        self.cold.get_idx(idx)
353    }
354
355    /// Bytecode-VM fast path: write a cold-variable slot by index.
356    #[inline]
357    pub fn cold_set_idx(&mut self, idx: usize, val: f64) {
358        self.cold.set_idx(idx, val);
359    }
360
361    /// Pre-seed the cold slab with MD2's documented default values. Mirrors
362    /// the old `init_defaults` behaviour (which used to write into the
363    /// `HashMapContext`) but routes the values through the slab so the
364    /// bytecode VM and the evalexpr `Context` impls below see the same
365    /// initial state.
366    fn seed_cold_defaults(cold: &mut ColdSlab) {
367        // Time / frame / fps.
368        cold.set_by_name("time", 0.0);
369        cold.set_by_name("frame", 0.0);
370        cold.set_by_name("fps", 60.0);
371        cold.set_by_name("progress", 0.0);
372
373        // Audio bands.
374        cold.set_by_name("bass", 0.0);
375        cold.set_by_name("mid", 0.0);
376        cold.set_by_name("treb", 0.0);
377        cold.set_by_name("bass_att", 0.0);
378        cold.set_by_name("mid_att", 0.0);
379        cold.set_by_name("treb_att", 0.0);
380
381        // Motion parameters.
382        cold.set_by_name("zoom", 1.0);
383        cold.set_by_name("zoomexp", 1.0);
384        cold.set_by_name("rot", 0.0);
385        cold.set_by_name("warp", 0.0);
386        cold.set_by_name("cx", 0.5);
387        cold.set_by_name("cy", 0.5);
388        cold.set_by_name("dx", 0.0);
389        cold.set_by_name("dy", 0.0);
390        cold.set_by_name("sx", 1.0);
391        cold.set_by_name("sy", 1.0);
392
393        // Wave parameters.
394        cold.set_by_name("wave_r", 1.0);
395        cold.set_by_name("wave_g", 1.0);
396        cold.set_by_name("wave_b", 1.0);
397        cold.set_by_name("wave_a", 1.0);
398        cold.set_by_name("wave_x", 0.5);
399        cold.set_by_name("wave_y", 0.5);
400        cold.set_by_name("wave_mystery", 0.0);
401        cold.set_by_name("wave_mode", 0.0);
402
403        // Border parameters.
404        cold.set_by_name("ob_size", 0.0);
405        cold.set_by_name("ob_r", 0.0);
406        cold.set_by_name("ob_g", 0.0);
407        cold.set_by_name("ob_b", 0.0);
408        cold.set_by_name("ob_a", 0.0);
409        cold.set_by_name("ib_size", 0.0);
410        cold.set_by_name("ib_r", 0.0);
411        cold.set_by_name("ib_g", 0.0);
412        cold.set_by_name("ib_b", 0.0);
413        cold.set_by_name("ib_a", 0.0);
414
415        // Motion vectors.
416        cold.set_by_name("mv_x", 12.0);
417        cold.set_by_name("mv_y", 9.0);
418        cold.set_by_name("mv_dx", 0.0);
419        cold.set_by_name("mv_dy", 0.0);
420        cold.set_by_name("mv_l", 0.9);
421        cold.set_by_name("mv_r", 1.0);
422        cold.set_by_name("mv_g", 1.0);
423        cold.set_by_name("mv_b", 1.0);
424        cold.set_by_name("mv_a", 0.0);
425
426        // Decay + echo.
427        cold.set_by_name("decay", 0.98);
428        cold.set_by_name("echo_zoom", 1.0);
429        cold.set_by_name("echo_alpha", 0.0);
430        cold.set_by_name("echo_orient", 0.0);
431    }
432
433    /// Set a variable value.
434    pub fn set(&mut self, name: &str, value: f64) {
435        // Hot-var fast path: no allocation.
436        if self.hot.set_by_name(name, value) {
437            return;
438        }
439
440        // q-var fast path: write to the f64 array (+ mirror), no
441        // HashMap touch.
442        if let Some(idx) = q_index_of(name) {
443            self.q_vars[idx] = value;
444            self.q_values_mirror[idx] = Value::Float(value);
445            return;
446        }
447
448        // Cold path — index slab. Auto-interns the name on first reference;
449        // subsequent stores hit the same slot directly.
450        self.cold.set_by_name(name, value);
451    }
452
453    /// Get a variable value.
454    pub fn get(&self, name: &str) -> Option<f64> {
455        // Hot-var fast path
456        if let Some(idx) = hot_index_of(name) {
457            return Some(self.hot.slots[idx]);
458        }
459
460        // q-var fast path
461        if let Some(idx) = q_index_of(name) {
462            return Some(self.q_vars[idx]);
463        }
464
465        // Cold path — index slab lookup. Returns `None` for names that have
466        // never been interned (auto-init in the caller path treats that as
467        // `0.0`); explicit defaults seeded in `seed_cold_defaults` ensure
468        // every documented MD2 var is interned at context construction.
469        self.cold.get_by_name(name)
470    }
471
472    /// Bytecode-VM fast path: read a hot var by slot index (one of the
473    /// `HOT_*` constants). One `f64` array load — no enum-tag match.
474    #[inline]
475    pub fn hot_get_idx(&self, idx: usize) -> f64 {
476        self.hot.slots[idx]
477    }
478
479    /// Bytecode-VM fast path: write a hot var by slot index. Updates the
480    /// f64 array used by [`Self::hot_get_idx`] and the parallel `Value`
481    /// mirror used by evalexpr's `Context::get_value` contract.
482    #[inline]
483    pub fn hot_set_idx(&mut self, idx: usize, value: f64) {
484        self.hot.slots[idx] = value;
485        self.hot.slots_value[idx] = Value::Float(value);
486    }
487
488    /// Bytecode-VM fast path: read q[idx] where idx is 0..63 (so
489    /// `q_get_idx(0)` returns the value of `q1`).
490    #[inline]
491    pub fn q_get_idx(&self, idx: usize) -> f64 {
492        self.q_vars[idx]
493    }
494
495    /// Bytecode-VM fast path: write q[idx]. Updates the f64 array used
496    /// by [`Self::q_get_idx`] and the parallel `Value` mirror used by
497    /// evalexpr's `Context::get_value` contract.
498    #[inline]
499    pub fn q_set_idx(&mut self, idx: usize, value: f64) {
500        self.q_vars[idx] = value;
501        self.q_values_mirror[idx] = Value::Float(value);
502    }
503
504    /// Get the internal evalexpr context (cold vars + math functions).
505    pub fn inner(&self) -> &HashMapContext {
506        &self.context
507    }
508
509    /// Get a mutable reference to the internal evalexpr context. Most
510    /// call sites should instead pass `&mut MilkContext` directly to
511    /// evalexpr (the `Context` / `ContextWithMutableVariables` impls
512    /// below route hot-path reads/writes to [`HotVars`] without a
513    /// `String` alloc).
514    pub fn inner_mut(&mut self) -> &mut HashMapContext {
515        &mut self.context
516    }
517
518    /// Get a snapshot of all 64 q variables as `f64`. The internal
519    /// storage is `[Value; 64]` (to support evalexpr's `&Value` return
520    /// type for q-channel reads), so this conversion is on demand.
521    pub fn q_vars(&self) -> [f64; 64] {
522        std::array::from_fn(|i| self.q_get_idx(i))
523    }
524
525    /// Set pixel position for per-pixel evaluation.
526    pub fn set_pixel(&mut self, x: f64, y: f64, rad: f64, ang: f64) {
527        self.set("x", x);
528        self.set("y", y);
529        self.set("rad", rad);
530        self.set("ang", ang);
531    }
532
533    /// Set a variable (alias for set).
534    pub fn set_var(&mut self, name: &str, value: f64) {
535        self.set(name, value);
536    }
537
538    /// Set time variable.
539    pub fn set_time(&mut self, time: f64) {
540        self.set("time", time);
541    }
542
543    /// Set frame variable.
544    pub fn set_frame(&mut self, frame: f64) {
545        self.set("frame", frame);
546    }
547
548    /// Set audio variables (bass, mid, treble).
549    pub fn set_audio(&mut self, bass: f64, mid: f64, treb: f64) {
550        self.set("bass", bass);
551        self.set("mid", mid);
552        self.set("treb", treb);
553    }
554
555    /// Get a variable value (alias for get).
556    pub fn get_var(&self, name: &str) -> Option<f64> {
557        self.get(name)
558    }
559}
560
561impl Default for MilkContext {
562    fn default() -> Self {
563        Self::new()
564    }
565}
566
567// --- evalexpr Context impls --------------------------------------------------
568//
569// Letting `MilkContext` directly satisfy evalexpr's traits is what unlocks
570// the alloc-free hot path: when evalexpr evaluates a compiled `Node` it
571// calls `Context::get_value` and `ContextWithMutableVariables::set_value`
572// against whatever `&mut C` we pass in. Routing those calls through
573// [`HotVars`] for the per-point scratch vars means a write to `x` is just
574// a field store — no `String` clone, no HashMap probe.
575
576impl Context for MilkContext {
577    type NumericTypes = DefaultNumericTypes;
578
579    fn get_value(&self, identifier: &str) -> Option<&Value> {
580        if let Some(v) = self.hot.slot(identifier) {
581            return Some(v);
582        }
583        if let Some(idx) = q_index_of(identifier) {
584            return Some(&self.q_values_mirror[idx]);
585        }
586        self.cold.get_value_by_name(identifier)
587    }
588
589    fn call_function(&self, identifier: &str, argument: &Value) -> EvalexprResultValue {
590        self.context.call_function(identifier, argument)
591    }
592
593    fn are_builtin_functions_disabled(&self) -> bool {
594        self.context.are_builtin_functions_disabled()
595    }
596
597    fn set_builtin_functions_disabled(
598        &mut self,
599        disabled: bool,
600    ) -> EvalexprResult<(), Self::NumericTypes> {
601        self.context.set_builtin_functions_disabled(disabled)
602    }
603}
604
605impl ContextWithMutableVariables for MilkContext {
606    fn set_value(
607        &mut self,
608        identifier: String,
609        value: Value,
610    ) -> EvalexprResult<(), Self::NumericTypes> {
611        // Hot-var fast path. The String passed in by evalexpr gets
612        // dropped right after this branch — we never insert it.
613        // Replicates `HashMapContext`'s type-stickiness: hot slots are
614        // always `Value::Float`, so a non-Float / non-Int write raises
615        // `expected_float` instead of silently mutating the slot's
616        // type.
617        if let Some(idx) = hot_index_of(&identifier) {
618            let f = match value {
619                Value::Float(f) => f,
620                Value::Int(i) => i as f64,
621                other => return Err(EvalexprError::expected_float(other)),
622            };
623            self.hot.slots[idx] = f;
624            self.hot.slots_value[idx] = Value::Float(f);
625            return Ok(());
626        }
627
628        // q-var fast path. The qN entries don't live in the HashMapContext
629        // or the cold slab; this is the only path that mutates them.
630        if let Some(idx) = q_index_of(&identifier) {
631            let f = match value {
632                Value::Float(f) => f,
633                Value::Int(i) => i as f64,
634                other => return Err(EvalexprError::expected_float(other)),
635            };
636            self.q_vars[idx] = f;
637            self.q_values_mirror[idx] = Value::Float(f);
638            return Ok(());
639        }
640
641        // Cold path — index slab. The String passed in by evalexpr is
642        // consumed by `set_value_by_name` (interns it on first reference);
643        // subsequent writes hit the existing slot by index.
644        self.cold.set_value_by_name(&identifier, value);
645        Ok(())
646    }
647
648    fn remove_value(
649        &mut self,
650        identifier: &str,
651    ) -> EvalexprResult<Option<Value>, Self::NumericTypes> {
652        // Hot, q, and cold slab slots are permanent for the life of the
653        // context — pretend they can't be removed (evalexpr's API allows
654        // returning `None`).
655        if self.hot.slot(identifier).is_some() {
656            return Ok(None);
657        }
658        if q_index_of(identifier).is_some() {
659            return Ok(None);
660        }
661        if self.cold.get_value_by_name(identifier).is_some() {
662            return Ok(None);
663        }
664        self.context.remove_value(identifier)
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_create_context() {
674        let ctx = MilkContext::new();
675        assert_eq!(ctx.get("time"), Some(0.0));
676        assert_eq!(ctx.get("fps"), Some(60.0));
677    }
678
679    #[test]
680    fn test_set_get_variable() {
681        let mut ctx = MilkContext::new();
682
683        ctx.set("bass", 1.5);
684        assert_eq!(ctx.get("bass"), Some(1.5));
685
686        ctx.set("custom_var", 42.0);
687        assert_eq!(ctx.get("custom_var"), Some(42.0));
688    }
689
690    #[test]
691    fn test_q_variables() {
692        let mut ctx = MilkContext::new();
693
694        // Test all 64 q variables
695        for i in 1..=64 {
696            let name = format!("q{}", i);
697            ctx.set(&name, i as f64);
698            assert_eq!(ctx.get(&name), Some(i as f64));
699        }
700
701        // Verify q_vars array
702        assert_eq!(ctx.q_vars()[0], 1.0);
703        assert_eq!(ctx.q_vars()[63], 64.0);
704    }
705
706    #[test]
707    fn test_custom_variables_round_trip() {
708        // Custom (non-builtin) vars used to be tracked in a side
709        // HashMap; now they live in the underlying HashMapContext only.
710        // Confirm reads still work end-to-end.
711        let mut ctx = MilkContext::new();
712
713        ctx.set("my_var", 123.0);
714        ctx.set("another_var", 456.0);
715
716        assert_eq!(ctx.get("my_var"), Some(123.0));
717        assert_eq!(ctx.get("another_var"), Some(456.0));
718    }
719
720    #[test]
721    fn test_hot_vars_round_trip() {
722        let mut ctx = MilkContext::new();
723        for (name, val) in [
724            ("x", 0.25),
725            ("y", 0.75),
726            ("r", 0.1),
727            ("g", 0.2),
728            ("b", 0.3),
729            ("a", 0.4),
730            ("sample", 0.5),
731            ("value1", 0.6),
732            ("value2", 0.7),
733            ("rad", 1.5),
734            ("ang", 2.5),
735            ("instance", 7.0),
736            ("sides", 5.0),
737            ("tex_zoom", 1.5),
738            ("border_r", 0.9),
739        ] {
740            ctx.set(name, val);
741            assert_eq!(ctx.get(name), Some(val), "round-trip failed for {name}");
742        }
743    }
744
745    #[test]
746    fn test_hot_vars_visible_to_evalexpr() {
747        use evalexpr::build_operator_tree;
748        let mut ctx = MilkContext::new();
749        ctx.set("x", 3.0);
750        ctx.set("y", 4.0);
751        let tree = build_operator_tree::<DefaultNumericTypes>("x*x + y*y").unwrap();
752        let val = tree.eval_with_context_mut(&mut ctx).unwrap();
753        assert!(matches!(val, Value::Float(f) if (f - 25.0).abs() < 1e-9));
754    }
755
756    #[test]
757    fn test_evalexpr_assignment_lands_in_hot_slot() {
758        use evalexpr::build_operator_tree;
759        let mut ctx = MilkContext::new();
760        let tree = build_operator_tree::<DefaultNumericTypes>("x = 0.123").unwrap();
761        tree.eval_with_context_mut(&mut ctx).unwrap();
762        assert_eq!(ctx.get("x"), Some(0.123));
763    }
764}