Skip to main content

xil_core/script/
hints.rs

1//! Scriptwriter pipe-hints on a direction: a source filename and per-cue
2//! attribute overrides. Port of `_parse_direction_hint` and friends.
3
4use indexmap::IndexMap;
5
6/// Script-facing hint name → the `SfxEntry` field it populates. Add a key
7/// here to support a new attribute; nothing else needs to change.
8pub const HINT_ATTRS: [(&str, &str); 2] = [
9    ("play_volume_pct", "volume_percentage"),
10    ("play_duration_pct", "play_duration"),
11];
12
13/// Accepted range per target field, mirroring the SfxEntry validators.
14pub fn hint_range(field: &str) -> Option<(f64, f64)> {
15    match field {
16        "volume_percentage" => Some((0.0, 200.0)),
17        "play_duration" => Some((0.0, 100.0)),
18        _ => None,
19    }
20}
21
22/// Cue prefixes whose layers loop, so a play_duration percentage is
23/// meaningless — the mixer only honours it for MUSIC / SFX / BEAT.
24pub const LOOPED_CUE_PREFIXES: [&str; 2] = ["AMBIENCE:", "VINTAGE FILTER"];
25
26/// What a direction's pipe-hints yielded.
27#[derive(Debug, Default, PartialEq)]
28pub struct Hints {
29    /// The direction text with consumed segments removed.
30    pub clean: String,
31    /// `SFX/<slug>/<file>` when a filename segment was present.
32    pub source: Option<String>,
33    /// Config-field-keyed overrides.
34    pub overrides: IndexMap<String, f64>,
35}
36
37/// Split `raw` into its direction text, source hint and attribute hints.
38///
39/// Segments after the first are classified independently and order-free: a
40/// `.mp3`/`.wav` is the source (first wins), a `key=value` whose key is in
41/// [`HINT_ATTRS`] is an override, anything else is re-joined onto the text
42/// so a writer's prose note survives verbatim.
43///
44/// `warn` receives the message Python logs for a malformed value; one bad
45/// hint must never abort a whole script parse.
46pub fn parse_direction_hint(raw: &str, slug: &str, warn: &mut dyn FnMut(String)) -> Hints {
47    if !raw.contains(" | ") {
48        return Hints {
49            clean: raw.trim().to_string(),
50            source: None,
51            overrides: IndexMap::new(),
52        };
53    }
54
55    let parts: Vec<&str> = raw.split(" | ").collect();
56    let head = parts[0].trim().to_string();
57    let mut source: Option<String> = None;
58    let mut overrides: IndexMap<String, f64> = IndexMap::new();
59    let mut unconsumed: Vec<String> = Vec::new();
60
61    for seg in &parts[1..] {
62        let seg = seg.trim();
63        if seg.ends_with(".mp3") || seg.ends_with(".wav") {
64            if source.is_none() {
65                let prefix = if slug.is_empty() {
66                    "SFX".to_string()
67                } else {
68                    format!("SFX/{slug}")
69                };
70                source = Some(format!("{prefix}/{seg}"));
71            } else {
72                unconsumed.push(seg.to_string());
73            }
74            continue;
75        }
76        let Some((key, value)) = seg.split_once('=') else {
77            unconsumed.push(seg.to_string());
78            continue;
79        };
80        let field = HINT_ATTRS
81            .iter()
82            .find(|(k, _)| *k == key.trim())
83            .map(|(_, f)| *f);
84        let Some(field) = field else {
85            unconsumed.push(seg.to_string());
86            continue;
87        };
88        match parse_hint_value(field, value.trim(), raw, warn) {
89            Some(num) => {
90                overrides.insert(field.to_string(), num);
91            }
92            None => unconsumed.push(seg.to_string()),
93        }
94    }
95
96    let clean = if unconsumed.is_empty() {
97        head
98    } else {
99        let mut all = vec![head];
100        all.extend(unconsumed);
101        all.join(" | ")
102    };
103    Hints {
104        clean,
105        source,
106        overrides,
107    }
108}
109
110fn parse_hint_value(
111    field: &str,
112    value: &str,
113    raw: &str,
114    warn: &mut dyn FnMut(String),
115) -> Option<f64> {
116    let (lo, hi) = hint_range(field)?;
117    let cleaned = value.trim_end_matches('%').trim();
118    let Ok(num) = cleaned.parse::<f64>() else {
119        warn(format!(
120            "  Ignoring non-numeric hint value in [{raw}]: {}",
121            py_repr(value)
122        ));
123        return None;
124    };
125    if !(lo..=hi).contains(&num) {
126        warn(format!(
127            "  Ignoring out-of-range {field} in [{raw}]: {value} (expected {}–{})",
128            fmt_g(lo),
129            fmt_g(hi)
130        ));
131        return None;
132    }
133    Some(num)
134}
135
136/// Python `repr()` of a string: single quotes unless the value contains one.
137pub fn py_repr(s: &str) -> String {
138    if s.contains('\'') && !s.contains('"') {
139        format!("\"{s}\"")
140    } else {
141        format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
142    }
143}
144
145/// Python's `%g` / `f"{v:g}"`: shortest form, trailing zeros dropped.
146pub fn fmt_g(v: f64) -> String {
147    if v == v.trunc() && v.abs() < 1e16 {
148        return format!("{}", v as i64);
149    }
150    let s = format!("{v:e}");
151    let exp: i32 = s
152        .split_once('e')
153        .map(|(_, e)| e.parse().unwrap_or(0))
154        .unwrap_or(0);
155    if (-5..6).contains(&exp) {
156        let mut out = format!("{v:.*}", (5 - exp).max(0) as usize);
157        if out.contains('.') {
158            out = out.trim_end_matches('0').trim_end_matches('.').to_string();
159        }
160        out
161    } else {
162        format!("{v:e}")
163    }
164}
165
166/// Render an override back into script-hint form (`play_volume_pct=20%`).
167/// Inverse of the [`HINT_ATTRS`] lookup, used by the script regenerator.
168pub fn format_hint_attr(field: &str, value: f64) -> String {
169    let name = HINT_ATTRS
170        .iter()
171        .find(|(_, f)| *f == field)
172        .map(|(k, _)| *k)
173        .unwrap_or(field);
174    format!("{name}={}%", fmt_g(value))
175}
176
177/// Narrow attribute hints to the ones this cue can use: silence cues take
178/// none, looped layers drop `play_duration`.
179pub fn filter_sfx_overrides(
180    key_text: &str,
181    entry_is_silence: bool,
182    overrides: &IndexMap<String, f64>,
183    warn: Option<&mut dyn FnMut(String)>,
184) -> IndexMap<String, f64> {
185    if overrides.is_empty() || entry_is_silence {
186        return IndexMap::new();
187    }
188    let mut allowed = overrides.clone();
189    if allowed.contains_key("play_duration")
190        && LOOPED_CUE_PREFIXES.iter().any(|p| key_text.starts_with(p))
191    {
192        allowed.shift_remove("play_duration");
193        if let Some(w) = warn {
194            w(format!(
195                "  Ignoring play_duration_pct on looped cue [{key_text}]"
196            ));
197        }
198    }
199    allowed
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn hints(raw: &str, slug: &str) -> (Hints, Vec<String>) {
207        let mut warns = Vec::new();
208        let h = parse_direction_hint(raw, slug, &mut |m| warns.push(m));
209        (h, warns)
210    }
211
212    #[test]
213    fn no_pipe_means_no_hints() {
214        let (h, w) = hints("  SFX: DOOR OPENS  ", "the413");
215        assert_eq!(
216            h,
217            Hints {
218                clean: "SFX: DOOR OPENS".into(),
219                source: None,
220                overrides: IndexMap::new()
221            }
222        );
223        assert!(w.is_empty());
224    }
225
226    #[test]
227    fn source_and_attributes_are_order_free() {
228        let (h, _) = hints(
229            "OUTRO MUSIC | play_volume_pct=20% | sundy.mp3 | play_duration_pct=35",
230            "the413",
231        );
232        assert_eq!(h.clean, "OUTRO MUSIC");
233        assert_eq!(h.source.as_deref(), Some("SFX/the413/sundy.mp3"));
234        assert_eq!(h.overrides["volume_percentage"], 20.0);
235        assert_eq!(h.overrides["play_duration"], 35.0);
236    }
237
238    #[test]
239    fn slugless_source_lands_in_the_flat_dir() {
240        let (h, _) = hints("SFX: X | a.wav", "");
241        assert_eq!(h.source.as_deref(), Some("SFX/a.wav"));
242    }
243
244    #[test]
245    fn unknown_segments_are_rejoined_not_swallowed() {
246        let (h, _) = hints(
247            "SFX: DOOR | some notes | second.mp3 | first.mp3 | nope=1",
248            "s",
249        );
250        assert_eq!(
251            h.source.as_deref(),
252            Some("SFX/s/second.mp3"),
253            "first filename wins"
254        );
255        assert_eq!(h.clean, "SFX: DOOR | some notes | first.mp3 | nope=1");
256    }
257
258    #[test]
259    fn bad_values_warn_and_are_kept_as_text() {
260        let (h, w) = hints("SFX: X | play_volume_pct=abc", "s");
261        assert_eq!(h.clean, "SFX: X | play_volume_pct=abc");
262        assert_eq!(
263            w,
264            vec!["  Ignoring non-numeric hint value in [SFX: X | play_volume_pct=abc]: 'abc'"]
265        );
266
267        let (h, w) = hints("SFX: X | play_volume_pct=500", "s");
268        assert_eq!(h.clean, "SFX: X | play_volume_pct=500");
269        assert_eq!(w[0], "  Ignoring out-of-range volume_percentage in [SFX: X | play_volume_pct=500]: 500 (expected 0–200)");
270    }
271
272    #[test]
273    fn looped_cues_drop_play_duration_only() {
274        let mut o = IndexMap::new();
275        o.insert("play_duration".to_string(), 50.0);
276        o.insert("volume_percentage".to_string(), 30.0);
277        let mut warns = Vec::new();
278        let kept = filter_sfx_overrides("AMBIENCE: RAIN", false, &o, Some(&mut |m| warns.push(m)));
279        assert_eq!(kept.len(), 1);
280        assert_eq!(kept["volume_percentage"], 30.0);
281        assert_eq!(
282            warns,
283            vec!["  Ignoring play_duration_pct on looped cue [AMBIENCE: RAIN]"]
284        );
285        assert!(
286            filter_sfx_overrides("BEAT", true, &o, None).is_empty(),
287            "silence takes none"
288        );
289        assert_eq!(filter_sfx_overrides("SFX: X", false, &o, None).len(), 2);
290    }
291
292    #[test]
293    fn hint_attr_round_trips_without_trailing_zeros() {
294        assert_eq!(
295            format_hint_attr("volume_percentage", 20.0),
296            "play_volume_pct=20%"
297        );
298        assert_eq!(
299            format_hint_attr("play_duration", 35.5),
300            "play_duration_pct=35.5%"
301        );
302        assert_eq!(fmt_g(0.5), "0.5");
303        assert_eq!(fmt_g(100.0), "100");
304    }
305}