Skip to main content

rgpre/import/
menu_ini.rs

1//! Data-defined book-menu pages; see `docs/config/menu-ini.md`.
2use std::collections::BTreeMap;
3
4/// Global menu flags relevant to cinematic visibility.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct MenuGeneral {
7    pub force_movies: bool,
8}
9
10impl MenuGeneral {
11    /// Missing force_movies is zero; the first case-insensitive occurrence wins.
12    pub fn parse(content: &str) -> Result<Self, String> {
13        let mut general = false;
14        let mut force_movies = false;
15        let mut seen = false;
16        for line in content.lines() {
17            let line = line.split(';').next().unwrap_or_default().trim();
18            if line.starts_with('[') {
19                general = line.eq_ignore_ascii_case("[general]");
20            } else if general
21                && let Some((key, value)) = line.split_once('=')
22                && key.trim().eq_ignore_ascii_case("force_movies")
23                && !seen
24            {
25                seen = true;
26                force_movies = match value.trim() {
27                    "0" => false,
28                    "1" => true,
29                    _ => return Err("MENU.INI [general] force_movies: expected 0 or 1".into()),
30                };
31            }
32        }
33        Ok(Self { force_movies })
34    }
35}
36
37/// A TEXBSI archive and image index used as a page surface.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct MenuTexture {
40    pub set: u16,
41    pub index: u16,
42}
43
44/// Text and interaction fields from one sequentially indexed menu element.
45/// Coordinates are texels on the selected page texture, not screen pixels.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct MenuEntry {
48    pub text: String,
49    pub position: [i32; 2],
50    pub texture: usize,
51    /// 0 = left, 1 = center, 2 = right.
52    pub justify: u8,
53    /// Widget/key-name output in page texels, with the same alignment codes.
54    pub output_position: [i32; 2],
55    pub output_justify: u8,
56    /// Inclusive bounds; missing fields default to zero.
57    pub slider: [i32; 2],
58    pub selectable: bool,
59    pub default: bool,
60    pub grayed: bool,
61    /// Engine-defined action ID, independent of the displayed label.
62    pub action: i32,
63}
64
65/// One page's optional texture references and ordered entries.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MenuPage {
68    pub textures: [Option<MenuTexture>; 2],
69    pub entries: Vec<MenuEntry>,
70}
71
72impl MenuPage {
73    /// Parse `[pageN]` from MENU.INI. Missing numeric element fields default to
74    /// zero, as in the engine. Rejects invalid recognized values, missing texture
75    /// references, and gaps in the text indices. Unrelated fields are ignored.
76    pub fn parse(content: &str, page: usize) -> Result<Self, String> {
77        let section = format!("[page{page}]");
78        let context = format!("MENU.INI {section}");
79        let mut fields = BTreeMap::new();
80        let mut active = false;
81        for line in content.lines() {
82            let line = line.split(';').next().unwrap_or_default().trim();
83            if line.starts_with('[') {
84                active = line.eq_ignore_ascii_case(&section);
85            } else if active && let Some((key, value)) = line.split_once('=') {
86                fields
87                    .entry(key.trim().to_ascii_lowercase())
88                    .or_insert(value.trim());
89            }
90        }
91        let number = |name: &str, index: usize, required: bool| -> Result<i32, String> {
92            let key = format!("{name}[{index}]");
93            match fields.get(&key) {
94                Some(value) => value
95                    .parse()
96                    .map_err(|_| format!("{context} {key}: invalid integer {value:?}")),
97                None if required => Err(format!("{context}: missing {key}")),
98                None => Ok(0),
99            }
100        };
101        let bounded = |name: &str, index, maximum, required| -> Result<i32, String> {
102            let value = number(name, index, required)?;
103            if !(0..=maximum).contains(&value) {
104                return Err(format!(
105                    "{context} {name}[{index}]: expected 0..{maximum}, got {value}"
106                ));
107            }
108            Ok(value)
109        };
110        let mut textures = [None; 2];
111        for (index, texture) in textures.iter_mut().enumerate() {
112            if index == 0
113                || fields.contains_key(&format!("texture_set[{index}]"))
114                || fields.contains_key(&format!("texture_index[{index}]"))
115            {
116                *texture = Some(MenuTexture {
117                    set: bounded("texture_set", index, i32::from(u16::MAX), true)? as u16,
118                    index: bounded("texture_index", index, i32::from(u16::MAX), true)? as u16,
119                });
120            }
121        }
122        let mut entries = Vec::new();
123        while let Some(text) = fields.get(&format!("text[{}]", entries.len())) {
124            let index = entries.len();
125            let texture = bounded("texture", index, 1, false)? as usize;
126            if textures[texture].is_none() {
127                return Err(format!(
128                    "{context} texture[{index}]: undefined texture slot {texture}"
129                ));
130            }
131            let slider = [
132                number("slider_min", index, false)?,
133                number("slider_max", index, false)?,
134            ];
135            if slider[0] > slider[1] {
136                return Err(format!(
137                    "{context} slider_min[{index}]: exceeds slider_max[{index}]"
138                ));
139            }
140            entries.push(MenuEntry {
141                text: (*text).to_owned(),
142                position: [
143                    number("text_x", index, false)?,
144                    number("text_y", index, false)?,
145                ],
146                texture,
147                justify: bounded("justify", index, 2, false)? as u8,
148                output_position: [
149                    number("output_x", index, false)?,
150                    number("output_y", index, false)?,
151                ],
152                output_justify: bounded("output_justify", index, 2, false)? as u8,
153                slider,
154                selectable: bounded("selectable", index, 1, false)? != 0,
155                default: bounded("default", index, 1, false)? != 0,
156                grayed: bounded("grayed", index, 1, false)? != 0,
157                action: number("action", index, false)?,
158            });
159        }
160        if entries.is_empty() {
161            return Err(format!("{context}: missing text[0]"));
162        }
163        for key in fields.keys() {
164            if let Some(index) = key.strip_prefix("text[").and_then(|s| s.strip_suffix(']')) {
165                let index: usize = index
166                    .parse()
167                    .map_err(|_| format!("{context}: invalid key {key}"))?;
168                if index >= entries.len() {
169                    return Err(format!("{context}: nonsequential {key}"));
170                }
171            }
172        }
173        Ok(Self { textures, entries })
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    const PAGE: &str = "[page0]\ntexture_set[0]=9\ntexture_set[1]=8\ntexture_index[0]=3\ntexture_index[1]=2\ntext[0]=Changed label\ntext_x[0]=71\ntext_y[0]=24\ntexture[0]=1\naction[0]=-2\nselectable[0]=1\ntext[1]=Other\n[page1]\ntext[0]=Different page\n";
181
182    #[test]
183    fn cinematic_visibility_flag_is_section_scoped() {
184        assert!(
185            MenuGeneral::parse("[general]\nforce_movies=1\n[page3]\nforce_movies=0")
186                .unwrap()
187                .force_movies
188        );
189        assert!(!MenuGeneral::parse("").unwrap().force_movies);
190        assert!(MenuGeneral::parse("[general]\nforce_movies=bad").is_err());
191    }
192
193    #[test]
194    fn reads_labels_layout_actions_and_zero_defaults_from_data() {
195        let page = MenuPage::parse(PAGE, 0).unwrap();
196        assert_eq!(
197            page.textures,
198            [
199                Some(MenuTexture { set: 9, index: 3 }),
200                Some(MenuTexture { set: 8, index: 2 })
201            ]
202        );
203        assert_eq!(
204            page.entries[0],
205            MenuEntry {
206                text: "Changed label".into(),
207                position: [71, 24],
208                texture: 1,
209                justify: 0,
210                output_position: [0, 0],
211                output_justify: 0,
212                slider: [0, 0],
213                selectable: true,
214                default: false,
215                grayed: false,
216                action: -2,
217            }
218        );
219        assert_eq!(page.entries[1].position, [0, 0]);
220    }
221
222    #[test]
223    fn single_surface_settings_page_preserves_widget_fields() {
224        let text = "[page5]\ntexture_set[0]=289\ntexture_index[0]=15\ntext[0]=Control\noutput_x[0]=20\noutput_y[0]=-4\noutput_justify[0]=2\nslider_min[0]=-10\nslider_max[0]=10\n";
225        let page = MenuPage::parse(text, 5).unwrap();
226        assert_eq!(page.textures[1], None);
227        assert_eq!(page.entries[0].output_position, [20, -4]);
228        assert_eq!(page.entries[0].output_justify, 2);
229        assert_eq!(page.entries[0].slider, [-10, 10]);
230        for extra in ["texture[0]=1", "texture_set[1]=289"] {
231            assert!(MenuPage::parse(&format!("{text}{extra}\n"), 5).is_err());
232        }
233        assert!(
234            MenuPage::parse(&text.replace("slider_min[0]=-10", "slider_min[0]=11"), 5).is_err()
235        );
236    }
237
238    #[test]
239    fn rejects_missing_invalid_and_nonsequential_fields_with_context() {
240        for text in [
241            PAGE.replace("texture_index[0]=3", ""),
242            PAGE.replace("texture[0]=1", "texture[0]=2"),
243            PAGE.replace("text[1]=Other", "text[2]=Other"),
244            PAGE.replace("action[0]=-2", "action[0]=bad"),
245        ] {
246            assert!(
247                MenuPage::parse(&text, 0)
248                    .unwrap_err()
249                    .contains("MENU.INI [page0]")
250            );
251        }
252    }
253}