1use std::collections::BTreeMap;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct MenuGeneral {
7 pub force_movies: bool,
8}
9
10impl MenuGeneral {
11 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct MenuTexture {
40 pub set: u16,
41 pub index: u16,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct MenuEntry {
48 pub text: String,
49 pub position: [i32; 2],
50 pub texture: usize,
51 pub justify: u8,
53 pub output_position: [i32; 2],
55 pub output_justify: u8,
56 pub slider: [i32; 2],
58 pub selectable: bool,
59 pub default: bool,
60 pub grayed: bool,
61 pub action: i32,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MenuPage {
68 pub textures: [Option<MenuTexture>; 2],
69 pub entries: Vec<MenuEntry>,
70}
71
72impl MenuPage {
73 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(§ion);
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}