onedrop_parser/
milk_msg_ini.rs

1//! Parser for `MILK_MSG.INI` — MilkDrop's preset-side text overlay file.
2//!
3//! MilkDrop's text overlays come in two flavours: ephemeral
4//! pop-ups triggered by `T` (cycle to next) or `Y##` (load a
5//! specific slot) and "burned" messages that render once into the
6//! background. The INI lets the user pre-author up to 100 named
7//! messages, each with its own typography, colour, position and
8//! animation parameters; the engine drives the lifecycle.
9//!
10//! File shape:
11//!
12//! ```text
13//! [message01]
14//! text=Now playing — Aphex Twin
15//! font=2
16//! size=24
17//! x=0.5
18//! y=0.5
19//! r=255
20//! g=128
21//! b=0
22//! bold=0
23//! italic=0
24//! fadein=0.5
25//! fadeout=0.5
26//! duration=3.0
27//! burn=0
28//! ```
29//!
30//! Defaults mirror MD2's hard-coded fallbacks: white text (255/255/
31//! 255), regular weight, centred (`x=0.5, y=0.5`), 1 s fade-in /
32//! fade-out, 3 s sustain, font slot 0 at 32 px, no burn.
33
34use crate::error::Result;
35
36/// One pre-authored text overlay.
37#[derive(Debug, Clone, PartialEq)]
38pub struct MessageDef {
39    /// 1-based slot index from the section header (`[message01]`).
40    /// Slot 0 is reserved.
41    pub slot: u32,
42    /// Display string. Bytes are taken verbatim from the INI; the
43    /// renderer is responsible for picking glyphs (the bundled
44    /// Ubuntu-Light covers Basic Latin + Latin-1 Supplement).
45    pub text: String,
46    /// Font slot index (`0..15`). MD2 lets the user pre-load 16
47    /// fonts; OneDrop's MVP renderer always uses Ubuntu-Light
48    /// (slot 0) so non-zero indices currently fall back to the
49    /// default. Kept as parsed for forward-compat.
50    pub font: u32,
51    /// Point size of the text in *pixels*. MD2 measured in pixels
52    /// rather than typographic points; we keep that convention.
53    pub size: f32,
54    /// Centre x / y in normalised `[0, 1]` screen coords (top-left
55    /// origin). The renderer turns these into clip-space transforms.
56    pub x: f32,
57    pub y: f32,
58    /// Colour in 0..255 (we keep `u8` rather than `f32` since the
59    /// INI surface is integer-valued).
60    pub r: u8,
61    pub g: u8,
62    pub b: u8,
63    /// Style flags. `bold` and `italic` are advisory in the MVP
64    /// renderer (Ubuntu-Light is regular weight only); kept for the
65    /// future font-pack work.
66    pub bold: bool,
67    pub italic: bool,
68    /// Animation timing in seconds.
69    pub fade_in_s: f32,
70    pub fade_out_s: f32,
71    /// Time the message stays at full opacity *between* fade-in and
72    /// fade-out. Total on-screen time = fade_in + duration +
73    /// fade_out.
74    pub duration_s: f32,
75    /// `burn=1` writes the message once into `render_texture` on
76    /// completion (so it persists through the warp feedback loop)
77    /// and then drops it from the active list.
78    pub burn: bool,
79}
80
81impl Default for MessageDef {
82    fn default() -> Self {
83        Self {
84            slot: 0,
85            text: String::new(),
86            font: 0,
87            size: 32.0,
88            x: 0.5,
89            y: 0.5,
90            r: 255,
91            g: 255,
92            b: 255,
93            bold: false,
94            italic: false,
95            fade_in_s: 1.0,
96            fade_out_s: 1.0,
97            duration_s: 3.0,
98            burn: false,
99        }
100    }
101}
102
103/// Parse a `MILK_MSG.INI` body. Sections with an empty `text=` are
104/// silently dropped (matches MD2's tolerant load). Returns the
105/// parsed messages sorted by slot.
106pub fn parse_milk_msg_ini(input: &str) -> Result<Vec<MessageDef>> {
107    let mut out: Vec<MessageDef> = Vec::new();
108    let mut current: Option<MessageDef> = None;
109
110    let flush = |out: &mut Vec<MessageDef>, def: MessageDef| {
111        if !def.text.is_empty() {
112            out.push(def);
113        }
114    };
115
116    for raw_line in input.lines() {
117        let line = raw_line.trim();
118        if line.is_empty() || line.starts_with("//") || line.starts_with(';') {
119            continue;
120        }
121
122        if let Some(rest) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
123            if let Some(def) = current.take() {
124                flush(&mut out, def);
125            }
126            let lower = rest.to_ascii_lowercase();
127            if let Some(num) = lower.strip_prefix("message") {
128                if let Ok(slot) = num.trim_start_matches('0').parse::<u32>() {
129                    current = Some(MessageDef {
130                        slot,
131                        ..Default::default()
132                    });
133                } else if num.trim_start_matches('0').is_empty() {
134                    current = Some(MessageDef::default());
135                }
136            }
137            continue;
138        }
139
140        let Some(eq) = line.find('=') else { continue };
141        let key = line[..eq].trim().to_ascii_lowercase();
142        let value = line[eq + 1..].trim();
143        let Some(def) = current.as_mut() else {
144            continue;
145        };
146
147        match key.as_str() {
148            "text" => def.text = value.to_string(),
149            "font" => def.font = value.parse().unwrap_or(def.font),
150            "size" => def.size = value.parse().unwrap_or(def.size),
151            "x" => def.x = value.parse().unwrap_or(def.x),
152            "y" => def.y = value.parse().unwrap_or(def.y),
153            "r" => def.r = value.parse().unwrap_or(def.r),
154            "g" => def.g = value.parse().unwrap_or(def.g),
155            "b" => def.b = value.parse().unwrap_or(def.b),
156            "bold" => def.bold = parse_bool(value),
157            "italic" => def.italic = parse_bool(value),
158            "fadein" | "fade_in" => def.fade_in_s = value.parse().unwrap_or(def.fade_in_s),
159            "fadeout" | "fade_out" => def.fade_out_s = value.parse().unwrap_or(def.fade_out_s),
160            "duration" => def.duration_s = value.parse().unwrap_or(def.duration_s),
161            "burn" => def.burn = parse_bool(value),
162            _ => {} // unknown keys silently dropped
163        }
164    }
165    if let Some(def) = current.take() {
166        flush(&mut out, def);
167    }
168    out.sort_by_key(|m| m.slot);
169    Ok(out)
170}
171
172fn parse_bool(s: &str) -> bool {
173    let lower = s.trim().to_ascii_lowercase();
174    !(lower.is_empty() || lower == "0" || lower == "false" || lower == "no" || lower == "off")
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parses_complete_message() {
183        let input = "\
184[message01]
185text=Hello world
186font=2
187size=24
188x=0.25
189y=0.75
190r=128
191g=200
192b=255
193bold=1
194italic=0
195fadein=0.5
196fadeout=0.5
197duration=2.0
198burn=1
199";
200        let msgs = parse_milk_msg_ini(input).unwrap();
201        assert_eq!(msgs.len(), 1);
202        let m = &msgs[0];
203        assert_eq!(m.slot, 1);
204        assert_eq!(m.text, "Hello world");
205        assert_eq!(m.font, 2);
206        assert!((m.size - 24.0).abs() < 1e-6);
207        assert!((m.x - 0.25).abs() < 1e-6);
208        assert!((m.y - 0.75).abs() < 1e-6);
209        assert_eq!(m.r, 128);
210        assert_eq!(m.g, 200);
211        assert_eq!(m.b, 255);
212        assert!(m.bold);
213        assert!(!m.italic);
214        assert!((m.fade_in_s - 0.5).abs() < 1e-6);
215        assert!((m.duration_s - 2.0).abs() < 1e-6);
216        assert!(m.burn);
217    }
218
219    #[test]
220    fn defaults_fill_missing_keys() {
221        let input = "\
222[message03]
223text=Just text
224";
225        let msgs = parse_milk_msg_ini(input).unwrap();
226        assert_eq!(msgs.len(), 1);
227        let m = &msgs[0];
228        assert_eq!(m.slot, 3);
229        assert_eq!(m.text, "Just text");
230        assert_eq!(m.font, 0);
231        assert_eq!(m.r, 255);
232        assert_eq!(m.g, 255);
233        assert_eq!(m.b, 255);
234        assert!(!m.bold);
235        assert!(!m.italic);
236        assert!((m.size - 32.0).abs() < 1e-6);
237    }
238
239    #[test]
240    fn drops_messages_with_no_text() {
241        let input = "\
242[message02]
243font=1
244size=20
245";
246        let msgs = parse_milk_msg_ini(input).unwrap();
247        assert!(msgs.is_empty());
248    }
249
250    #[test]
251    fn malformed_numeric_falls_back_to_default() {
252        let input = "\
253[message01]
254text=foo
255size=not-a-number
256r=300
257";
258        let msgs = parse_milk_msg_ini(input).unwrap();
259        // `size` falls back to default 32.0, `r` falls back to default 255.
260        assert!((msgs[0].size - 32.0).abs() < 1e-6);
261        assert_eq!(msgs[0].r, 255);
262    }
263
264    #[test]
265    fn sorts_by_slot() {
266        let input = "\
267[message05]
268text=fifth
269[message02]
270text=second
271[message09]
272text=ninth
273";
274        let msgs = parse_milk_msg_ini(input).unwrap();
275        let slots: Vec<u32> = msgs.iter().map(|m| m.slot).collect();
276        assert_eq!(slots, vec![2, 5, 9]);
277    }
278}