onedrop_parser/milk_img_ini.rs
1//! Parser for `MILK_IMG.INI` — MilkDrop's sprite definition file.
2//!
3//! The MilkDrop sprite system (key `K`) loads up to 100 indexed sprites
4//! from a single INI file. Each sprite entry references an image file
5//! and carries an `init` block plus up to 8 per-frame equation blocks
6//! (`code_1`..`code_8`). At runtime the per-frame block updates the
7//! sprite's `x, y, sx, sy, rot, blendmode, a, r, g, b, …` variables,
8//! and the renderer draws a textured quad accordingly.
9//!
10//! File shape (lifted from `winamp_milkdrop_help.txt`):
11//!
12//! ```text
13//! [img01]
14//! img=sprite.tga
15//! init_1=foo = 0.5;
16//! code_1=x = .5 + sin(time)*.3;
17//! code_2=y = .5 + cos(time)*.3;
18//! code_3=
19//! ...
20//! code_8=
21//! burn=0
22//!
23//! [img02]
24//! img=other.png
25//! ...
26//! ```
27//!
28//! Per-frame variable contract (the MilkDrop sprite VM exposes these
29//! to the equations — names handled by the engine, not the parser):
30//!
31//! - `x, y` : centre position in `[0, 1]` (top-left origin).
32//! - `sx, sy` : per-axis scale (1.0 = native sprite size).
33//! - `rot` : rotation in radians.
34//! - `flipx, flipy` : 0/1 mirror toggles.
35//! - `repeatx, repeaty`: tile counts for `> 1`; 1 = single quad.
36//! - `blendmode` : 0=alpha, 1=decal, 2=additive (others rarely used).
37//! - `a, r, g, b` : uniform tint (a alpha, rgb multiplier).
38//! - `a1..a4, r1..r4, g1..g4, b1..b4`: per-corner colour (gradient).
39//! - `time, frame` : passthrough from the engine.
40//! - `done` : write 1 to mark the sprite for removal.
41//!
42//! The parser is liberal: unknown keys, bad section names, missing
43//! `img=` fields are simply skipped (matching MD2's tolerant load),
44//! returning whatever valid entries it could extract.
45
46use crate::error::Result;
47
48/// A single sprite slot parsed from a `[imgNN]` section.
49#[derive(Debug, Clone, Default, PartialEq)]
50pub struct SpriteDef {
51 /// 1-based slot index from the section header (`[img01]` → `1`).
52 /// Slot 0 is reserved (MD2 used 1..=100). Indices are kept so the
53 /// keymap UX can target a specific slot ("press `5` after `K`
54 /// to load slot 5" was the historical chord on Windows).
55 pub slot: u32,
56 /// Image file name as referenced in the INI (`sprite.tga`).
57 /// Loading + path resolution is the engine's responsibility —
58 /// keeping the raw string here decouples the parser from disk
59 /// I/O and lets tests assert on the parsed shape directly.
60 pub img: String,
61 /// Optional `init_1=` block: one-shot equations evaluated at
62 /// sprite spawn. Empty string when absent.
63 pub init: String,
64 /// `code_1`..`code_8` per-frame equation blocks, joined into a
65 /// single semicolon-delimited string. MD2's 8-slot split was
66 /// purely a workaround for the 256-char-per-key limit of its
67 /// INI parser; the evaluator sees them as one block.
68 pub per_frame: String,
69 /// `burn=1` makes the sprite render once into `render_texture`
70 /// (so it feeds back through the warp loop) and then mark itself
71 /// for removal on the next frame.
72 pub burn: bool,
73}
74
75/// Parse a `MILK_IMG.INI` file body. Returns one [`SpriteDef`] per
76/// `[imgNN]` section that carries a non-empty `img=` field. Sections
77/// whose name doesn't match the expected pattern, sections with no
78/// image, and unknown keys are silently dropped (matches MD2's
79/// fail-open behaviour — a malformed entry shouldn't break the bank).
80///
81/// The returned vector is sorted by slot index ascending so callers
82/// can index it without re-sorting; gaps in the slot range are
83/// preserved (the index isn't densified).
84pub fn parse_milk_img_ini(input: &str) -> Result<Vec<SpriteDef>> {
85 let mut out: Vec<SpriteDef> = Vec::new();
86 let mut current: Option<SpriteDef> = None;
87 let mut code_blocks: [String; 8] = Default::default();
88
89 let flush = |out: &mut Vec<SpriteDef>, mut def: SpriteDef, code: &mut [String; 8]| {
90 // Stitch code_1..code_8 into one block at flush time. Drop
91 // empty blocks so the evaluator doesn't see dangling `;`.
92 let joined: Vec<&str> = code
93 .iter()
94 .map(|s| s.trim())
95 .filter(|s| !s.is_empty())
96 .collect();
97 if !joined.is_empty() {
98 def.per_frame = joined.join("; ");
99 }
100 *code = Default::default();
101 if !def.img.is_empty() {
102 out.push(def);
103 }
104 };
105
106 for raw_line in input.lines() {
107 // Strip comments. MD2 uses `;` AND `//`; either at start of
108 // a line is a full-line comment. We don't strip inline
109 // semicolons inside `code_N=` values — those are equation
110 // separators, not comments.
111 let line = raw_line.trim();
112 if line.is_empty() || line.starts_with("//") {
113 continue;
114 }
115 if line.starts_with(';') {
116 // Section-leading ';' is a comment, but `code_1=a;b` has
117 // semicolons as separators. Only strip when the entire
118 // line starts with one.
119 continue;
120 }
121
122 // Section header.
123 if let Some(rest) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
124 // Flush previous section first.
125 if let Some(def) = current.take() {
126 flush(&mut out, def, &mut code_blocks);
127 }
128 // Parse the [imgNN] header. Accept `img01`, `IMG12`, etc.
129 let lower = rest.to_ascii_lowercase();
130 if let Some(num) = lower.strip_prefix("img") {
131 if let Ok(slot) = num.trim_start_matches('0').parse::<u32>() {
132 current = Some(SpriteDef {
133 slot,
134 ..Default::default()
135 });
136 } else if num.trim_start_matches('0').is_empty() {
137 // `[img00]` → slot 0 (technically reserved but accept).
138 current = Some(SpriteDef::default());
139 }
140 }
141 continue;
142 }
143
144 // Key=value inside the current section.
145 let Some(eq) = line.find('=') else { continue };
146 let key = line[..eq].trim().to_ascii_lowercase();
147 let value = line[eq + 1..].trim().to_string();
148 let Some(def) = current.as_mut() else {
149 continue;
150 };
151
152 match key.as_str() {
153 "img" => def.img = value,
154 "init_1" | "init" => def.init = value,
155 "burn" => def.burn = value.trim() != "0" && !value.trim().is_empty(),
156 other => {
157 if let Some(idx_str) = other.strip_prefix("code_")
158 && let Ok(idx) = idx_str.parse::<usize>()
159 && (1..=8).contains(&idx)
160 {
161 code_blocks[idx - 1] = value;
162 }
163 // Unknown keys are silently dropped (MD2 fail-open).
164 }
165 }
166 }
167 // Final section.
168 if let Some(def) = current.take() {
169 flush(&mut out, def, &mut code_blocks);
170 }
171 out.sort_by_key(|s| s.slot);
172 Ok(out)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn parses_single_sprite_with_all_keys() {
181 let input = "\
182[img01]
183img=stars.tga
184init_1=foo=0.5;
185code_1=x = .5 + sin(time)*.3;
186code_2=y = .5 + cos(time)*.3;
187burn=1
188";
189 let sprites = parse_milk_img_ini(input).unwrap();
190 assert_eq!(sprites.len(), 1);
191 assert_eq!(sprites[0].slot, 1);
192 assert_eq!(sprites[0].img, "stars.tga");
193 assert_eq!(sprites[0].init, "foo=0.5;");
194 assert_eq!(
195 sprites[0].per_frame,
196 "x = .5 + sin(time)*.3;; y = .5 + cos(time)*.3;"
197 );
198 assert!(sprites[0].burn);
199 }
200
201 #[test]
202 fn drops_sections_with_no_img() {
203 // No `img=` line → silently dropped (MD2 fail-open).
204 let input = "\
205[img02]
206code_1=x = .5;
207";
208 let sprites = parse_milk_img_ini(input).unwrap();
209 assert!(sprites.is_empty());
210 }
211
212 #[test]
213 fn sorts_by_slot_ascending() {
214 let input = "\
215[img03]
216img=c.png
217[img01]
218img=a.png
219[img02]
220img=b.png
221";
222 let sprites = parse_milk_img_ini(input).unwrap();
223 let slots: Vec<u32> = sprites.iter().map(|s| s.slot).collect();
224 assert_eq!(slots, vec![1, 2, 3]);
225 }
226
227 #[test]
228 fn empty_code_blocks_are_skipped_in_join() {
229 let input = "\
230[img01]
231img=x.png
232code_1=a=1;
233code_3=b=2;
234code_5=
235";
236 let sprites = parse_milk_img_ini(input).unwrap();
237 // `code_2`, `code_4`, `code_5` are empty → only `a=1; b=2`
238 // makes it through.
239 assert_eq!(sprites[0].per_frame, "a=1;; b=2;");
240 }
241
242 #[test]
243 fn comment_lines_and_unknown_keys_are_tolerated() {
244 let input = "\
245; this is a comment
246// also a comment
247[img05]
248img=foo.png
249weirdkey=ignore me
250burn=0
251";
252 let sprites = parse_milk_img_ini(input).unwrap();
253 assert_eq!(sprites.len(), 1);
254 assert_eq!(sprites[0].slot, 5);
255 assert!(!sprites[0].burn);
256 }
257
258 #[test]
259 fn burn_is_truthy_on_any_nonzero_value() {
260 let input = "\
261[img01]
262img=a.png
263burn=1
264[img02]
265img=b.png
266burn=yes
267[img03]
268img=c.png
269burn=0
270[img04]
271img=d.png
272";
273 let sprites = parse_milk_img_ini(input).unwrap();
274 assert!(sprites[0].burn);
275 assert!(sprites[1].burn);
276 assert!(!sprites[2].burn);
277 assert!(!sprites[3].burn);
278 }
279
280 #[test]
281 fn returns_empty_for_empty_input() {
282 assert!(parse_milk_img_ini("").unwrap().is_empty());
283 }
284}