1use anyhow::bail;
2use anyhow::Context;
3use rpgmxp_types::Actor;
4use rpgmxp_types::Animation;
5use rpgmxp_types::Armor;
6use rpgmxp_types::Class;
7use rpgmxp_types::CommonEvent;
8use rpgmxp_types::Enemy;
9use rpgmxp_types::Item;
10use rpgmxp_types::Skill;
11use rpgmxp_types::State;
12use rpgmxp_types::Tileset;
13use rpgmxp_types::Troop;
14use rpgmxp_types::Weapon;
15use std::fmt::Write;
16
17pub fn decode_hex_u8(value: u8) -> Option<u8> {
22 match value {
23 b'0'..=b'9' => Some(value - b'0'),
24 b'a'..=b'f' => Some(value - b'a' + 10),
25 b'A'..=b'F' => Some(value - b'A' + 10),
26 _ => None,
27 }
28}
29
30pub fn is_map_file_name(file_name: &str, expected_extension: &str) -> bool {
36 file_name
37 .rsplit_once('.')
38 .and_then(|(file_name, extension)| {
39 if extension == expected_extension {
40 Some(file_name)
41 } else {
42 None
43 }
44 })
45 .and_then(|file_name| file_name.strip_prefix("Map"))
46 .is_some_and(|map_n| !map_n.is_empty() && map_n.chars().all(|c| c.is_ascii_digit()))
47}
48
49pub fn percent_escape_file_name(file_name: &str) -> String {
60 let mut escaped = String::with_capacity(file_name.len());
61 for c in file_name.chars() {
62 match c {
63 '%' | ':' | '*' | '/' | '<' | '>' | '?' => {
64 let c = u32::from(c);
65 write!(&mut escaped, "%{c:02x}").unwrap();
66 }
67 _ => {
68 escaped.push(c);
69 }
70 }
71 }
72 escaped
73}
74
75pub fn percent_unescape_file_name(file_name: &str) -> anyhow::Result<String> {
80 #[derive(PartialEq)]
81 enum State {
82 Normal,
83 ParsePercentEscape { index: usize, value: u8 },
84 }
85
86 let mut unescaped = String::with_capacity(file_name.len());
87 let mut state = State::Normal;
88 for c in file_name.chars() {
89 match (&mut state, c) {
90 (State::Normal, '%') => {
91 state = State::ParsePercentEscape { index: 0, value: 0 };
92 }
93 (State::Normal, c) => unescaped.push(c),
94 (State::ParsePercentEscape { index, value }, c) => {
95 let c = u8::try_from(c).context("invalid percent escape")?;
96 let c = crate::util::decode_hex_u8(c).context("invalid hex char")?;
97
98 *value |= c << (4 - (4 * *index));
99 *index += 1;
100
101 if *index == 2 {
102 let c = char::from(*value);
103 unescaped.push(c);
104
105 state = State::Normal;
106 }
107 }
108 }
109 }
110
111 if state != State::Normal {
112 bail!("incomplete percent escape");
113 }
114
115 Ok(unescaped)
116}
117
118pub trait ArrayLikeElement<'a>:
120 serde::Deserialize<'a> + serde::Serialize + ruby_marshal::FromValue<'a> + ruby_marshal::IntoValue
121{
122 fn type_display_name() -> &'static str;
124
125 fn name(&self) -> &str;
127}
128
129impl ArrayLikeElement<'_> for CommonEvent {
130 fn type_display_name() -> &'static str {
131 "common event"
132 }
133
134 fn name(&self) -> &str {
135 self.name.as_str()
136 }
137}
138
139impl ArrayLikeElement<'_> for Actor {
140 fn type_display_name() -> &'static str {
141 "actor"
142 }
143
144 fn name(&self) -> &str {
145 self.name.as_str()
146 }
147}
148
149impl ArrayLikeElement<'_> for Weapon {
150 fn type_display_name() -> &'static str {
151 "weapon"
152 }
153
154 fn name(&self) -> &str {
155 self.name.as_str()
156 }
157}
158
159impl ArrayLikeElement<'_> for Armor {
160 fn type_display_name() -> &'static str {
161 "armor"
162 }
163
164 fn name(&self) -> &str {
165 self.name.as_str()
166 }
167}
168
169impl ArrayLikeElement<'_> for Skill {
170 fn type_display_name() -> &'static str {
171 "skill"
172 }
173
174 fn name(&self) -> &str {
175 self.name.as_str()
176 }
177}
178
179impl ArrayLikeElement<'_> for State {
180 fn type_display_name() -> &'static str {
181 "state"
182 }
183
184 fn name(&self) -> &str {
185 self.name.as_str()
186 }
187}
188
189impl ArrayLikeElement<'_> for Item {
190 fn type_display_name() -> &'static str {
191 "item"
192 }
193
194 fn name(&self) -> &str {
195 self.name.as_str()
196 }
197}
198
199impl ArrayLikeElement<'_> for Enemy {
200 fn type_display_name() -> &'static str {
201 "enemy"
202 }
203
204 fn name(&self) -> &str {
205 self.name.as_str()
206 }
207}
208
209impl ArrayLikeElement<'_> for Class {
210 fn type_display_name() -> &'static str {
211 "class"
212 }
213
214 fn name(&self) -> &str {
215 self.name.as_str()
216 }
217}
218
219impl ArrayLikeElement<'_> for Troop {
220 fn type_display_name() -> &'static str {
221 "troop"
222 }
223
224 fn name(&self) -> &str {
225 self.name.as_str()
226 }
227}
228
229impl ArrayLikeElement<'_> for Tileset {
230 fn type_display_name() -> &'static str {
231 "tileset"
232 }
233
234 fn name(&self) -> &str {
235 self.name.as_str()
236 }
237}
238
239impl ArrayLikeElement<'_> for Animation {
240 fn type_display_name() -> &'static str {
241 "animation"
242 }
243
244 fn name(&self) -> &str {
245 self.name.as_str()
246 }
247}
248
249#[cfg(test)]
250mod test {
251 use super::*;
252
253 #[test]
254 fn decode_hex_u8_sanity() {
255 assert!(decode_hex_u8(b'F') == Some(15));
256 assert!(decode_hex_u8(b'G').is_none());
257 }
258
259 #[test]
260 fn is_map_file_name_sanity() {
261 assert!(is_map_file_name("Map001.rxdata", "rxdata"));
262 assert!(!is_map_file_name("Map001.json", "rxdata"));
263 assert!(is_map_file_name("Map001.json", "json"));
264 assert!(!is_map_file_name("Map001.rxdata", "json"));
265
266 assert!(!is_map_file_name("Map.json", "json"));
267 assert!(!is_map_file_name("Map", "json"));
268 }
269
270 #[test]
271 fn percent_escape_round_trip() {
272 let tests = ["hello.txt", "%world.json", "foo:bar.rxdata"];
273
274 for test in tests {
275 let escaped = percent_escape_file_name(test);
276 let unescaped =
277 percent_unescape_file_name(escaped.as_str()).expect("failed to percent unescape");
278
279 assert!(test == unescaped, "{test} != {unescaped}");
280 }
281 }
282}