rpgmxp_tool/commands/
compile_assets.rs1mod file_sink;
2mod vx;
3mod vx_ace;
4mod xp;
5
6use self::file_sink::FileSink;
7use crate::util::ArrayLikeElement;
8use crate::GameKind;
9use anyhow::bail;
10use anyhow::ensure;
11use anyhow::Context;
12use rpgm_common_types::Script;
13use rpgm_common_types::ScriptList;
14use ruby_marshal::IntoValue;
15use std::collections::BTreeMap;
16use std::path::Component as PathComponent;
17use std::path::Path;
18use std::path::PathBuf;
19use std::str::FromStr;
20use walkdir::WalkDir;
21
22fn set_extension_str(input: &str, extension: &str) -> String {
23 let stem = input
24 .rsplit_once('.')
25 .map(|(stem, _extension)| stem)
26 .unwrap_or(input);
27
28 format!("{stem}.{extension}")
29}
30
31#[derive(Debug)]
32enum Format {
33 Dir,
34 Rgssad,
35 Rgss2a,
36 Rgss3a,
37}
38
39impl FromStr for Format {
40 type Err = anyhow::Error;
41
42 fn from_str(input: &str) -> Result<Self, Self::Err> {
43 match input {
44 "dir" => Ok(Self::Dir),
45 "rgssad" => Ok(Self::Rgssad),
46 "rgss2a" => Ok(Self::Rgss2a),
47 "rgss3a" => Ok(Self::Rgss3a),
48 _ => bail!("unknown format \"{input}\""),
49 }
50 }
51}
52
53fn generate_ruby_data<T>(path: &Path) -> anyhow::Result<Vec<u8>>
54where
55 T: serde::de::DeserializeOwned + ruby_marshal::IntoValue,
56{
57 let map = std::fs::read_to_string(path)?;
58 let map: T = serde_json::from_str(&map)?;
59
60 let mut arena = ruby_marshal::ValueArena::new();
61 let handle = map.into_value(&mut arena)?;
62 arena.replace_root(handle);
63
64 let mut data = Vec::new();
65 ruby_marshal::dump(&mut data, &arena)?;
66
67 Ok(data)
68}
69
70fn generate_scripts_data(path: &Path) -> anyhow::Result<Vec<u8>> {
71 let mut scripts_map = BTreeMap::new();
72
73 for dir_entry in path.read_dir()? {
74 let dir_entry = dir_entry?;
75 let dir_entry_file_type = dir_entry.file_type()?;
76
77 ensure!(dir_entry_file_type.is_file());
78
79 let dir_entry_file_name = dir_entry.file_name();
80 let dir_entry_file_name = dir_entry_file_name
81 .to_str()
82 .context("non-unicode script name")?;
83 let dir_entry_file_stem = dir_entry_file_name
84 .strip_suffix(".rb")
85 .context("script is not an \"rb\" file")?;
86
87 let (script_index, escaped_script_name) = dir_entry_file_stem
88 .split_once('-')
89 .context("invalid script name format")?;
90 let script_index: usize = script_index.parse()?;
91 let unescaped_file_name = crate::util::percent_unescape_file_name(escaped_script_name)?;
92
93 println!(" packing script \"{escaped_script_name}\"");
94
95 let dir_entry_path = dir_entry.path();
96 let script_data = std::fs::read_to_string(dir_entry_path)?;
97
98 let old_entry = scripts_map.insert(
99 script_index,
100 Script {
101 data: script_data,
102 id: i32::try_from(script_index)? + 1,
103 name: unescaped_file_name,
104 },
105 );
106 if old_entry.is_some() {
107 bail!("duplicate scripts for index {script_index}");
108 }
109 }
110
111 let script_list = ScriptList {
113 scripts: scripts_map.into_values().collect(),
114 };
115
116 let mut arena = ruby_marshal::ValueArena::new();
117 let handle = script_list.into_value(&mut arena)?;
118 arena.replace_root(handle);
119
120 let mut data = Vec::new();
121 ruby_marshal::dump(&mut data, &arena)?;
122
123 Ok(data)
124}
125
126fn generate_arraylike_rx_data<T>(path: &Path) -> anyhow::Result<Vec<u8>>
127where
128 T: for<'a> ArrayLikeElement<'a>,
129{
130 fn load_json_str(
131 dir_entry: std::io::Result<std::fs::DirEntry>,
132 type_display_name: &str,
133 ) -> anyhow::Result<(usize, String)> {
134 let dir_entry = dir_entry?;
135 let dir_entry_file_type = dir_entry.file_type()?;
136
137 ensure!(dir_entry_file_type.is_file());
138
139 let dir_entry_file_name = dir_entry.file_name();
140 let dir_entry_file_name = dir_entry_file_name.to_str().context("non-unicode name")?;
141 let dir_entry_file_stem = dir_entry_file_name
142 .strip_suffix(".json")
143 .context("not a \"json\" file")?;
144
145 let (index, name) = dir_entry_file_stem
146 .split_once('-')
147 .context("invalid name format")?;
148 let name = crate::util::percent_unescape_file_name(name)?;
149 let index: usize = index.parse()?;
150
151 println!(" packing {type_display_name} \"{name}\"");
152
153 let dir_entry_path = dir_entry.path();
154 let json = std::fs::read_to_string(dir_entry_path)?;
155
156 Ok((index, json))
157 }
158
159 let type_display_name = T::type_display_name();
160 let mut map: BTreeMap<usize, T> = BTreeMap::new();
161
162 for dir_entry in path.read_dir()? {
163 let (index, json) = load_json_str(dir_entry, type_display_name)?;
164 let value: T = serde_json::from_str(&json)?;
165
166 let old_entry = map.insert(index, value);
167 if old_entry.is_some() {
168 bail!("duplicate {type_display_name} for index {index}");
169 }
170 }
171
172 let mut data = Vec::with_capacity(map.len() + 1);
174 data.push(None);
175 for value in map.into_values() {
176 data.push(Some(value));
177 }
178
179 let mut arena = ruby_marshal::ValueArena::new();
180 let handle = data.into_value(&mut arena)?;
181 arena.replace_root(handle);
182
183 let mut data = Vec::new();
184 ruby_marshal::dump(&mut data, &arena)?;
185
186 Ok(data)
187}
188
189fn generate_map_infos_data(path: &Path) -> anyhow::Result<Vec<u8>> {
190 let mut map: BTreeMap<i32, rpgm_common_types::MapInfo> = BTreeMap::new();
191
192 for dir_entry in path.read_dir()? {
193 let dir_entry = dir_entry?;
194 let dir_entry_file_type = dir_entry.file_type()?;
195
196 ensure!(dir_entry_file_type.is_file());
197
198 let dir_entry_file_name = dir_entry.file_name();
199 let dir_entry_file_name = dir_entry_file_name.to_str().context("non-unicode name")?;
200 let dir_entry_file_stem = dir_entry_file_name
201 .strip_suffix(".json")
202 .context("not a \"json\" file")?;
203
204 let (index, name) = dir_entry_file_stem
205 .split_once('-')
206 .context("invalid name format")?;
207 let index: i32 = index.parse()?;
208
209 println!(" packing map info \"{name}\"");
210
211 let dir_entry_path = dir_entry.path();
212 let json = std::fs::read_to_string(dir_entry_path)?;
213
214 let value: rpgm_common_types::MapInfo = serde_json::from_str(&json)?;
215
216 let old_entry = map.insert(index, value);
217 if old_entry.is_some() {
218 bail!("duplicate map info for index {index}");
219 }
220 }
221
222 let mut arena = ruby_marshal::ValueArena::new();
223 let handle = map.into_value(&mut arena)?;
224 arena.replace_root(handle);
225
226 let mut data = Vec::new();
227 ruby_marshal::dump(&mut data, &arena)?;
228
229 Ok(data)
230}
231
232#[derive(Debug, argh::FromArgs)]
233#[argh(
234 subcommand,
235 name = "compile-assets",
236 description = "recompile extracted assets from a folder"
237)]
238pub struct Options {
239 #[argh(positional, description = "the input folder path to compile")]
240 input: PathBuf,
241
242 #[argh(positional, description = "the output path")]
243 output: PathBuf,
244
245 #[argh(
246 option,
247 long = "format",
248 short = 'f',
249 description = "the output format. Defaults to detecting from the extension. Otherwise, \"dir\" is used."
250 )]
251 format: Option<Format>,
252
253 #[argh(
254 option,
255 long = "game",
256 short = 'g',
257 description = "the game type. Defaults to detecting from the output format. Must be provided if the output format is a dir."
258 )]
259 game: Option<GameKind>,
260
261 #[argh(
262 switch,
263 long = "overwrite",
264 description = "whether overwrite the output if it exists"
265 )]
266 pub overwrite: bool,
267}
268
269pub fn exec(mut options: Options) -> anyhow::Result<()> {
270 options.input = options
271 .input
272 .canonicalize()
273 .context("failed to canonicalize input path")?;
274
275 let format = match options.format {
276 Some(format) => format,
277 None => {
278 let extension = options
279 .output
280 .extension()
281 .map(|extension| extension.to_str().context("non-unicode extension"))
282 .transpose()?;
283 if extension == Some("rgssad") {
284 Format::Rgssad
285 } else if extension == Some("rgss2a") {
286 Format::Rgss2a
287 } else if extension == Some("rgss3a") {
288 Format::Rgss3a
289 } else {
290 Format::Dir
291 }
292 }
293 };
294
295 let mut file_sink = match format {
296 Format::Dir => FileSink::new_dir(&options.output, options.overwrite)?,
297 Format::Rgssad | Format::Rgss2a | Format::Rgss3a => {
298 FileSink::new_rgssad(&options.output, options.overwrite)?
299 }
300 };
301 let game_kind = options.game.map(Ok).unwrap_or_else(|| match format {
302 Format::Dir => {
303 bail!("need to provide game type with --game flag when outputting to a dir.")
304 }
305 Format::Rgssad => Ok(GameKind::Xp),
306 Format::Rgss2a => Ok(GameKind::Vx),
307 Format::Rgss3a => Ok(GameKind::VxAce),
308 })?;
309
310 for entry in WalkDir::new(&options.input) {
311 let entry = entry?;
312 let entry_file_type = entry.file_type();
313 let entry_path = entry.path();
314
315 let relative_path = entry_path.strip_prefix(&options.input)?;
316 let relative_path_components = relative_path
317 .components()
318 .map(|component| match component {
319 PathComponent::Normal(value) => value.to_str().context("non-unicode path"),
320 component => bail!("unexpected path component \"{component:?}\""),
321 })
322 .collect::<anyhow::Result<Vec<_>>>()?;
323
324 match game_kind {
325 GameKind::Xp => self::xp::compile(
326 entry_path,
327 entry_file_type,
328 relative_path,
329 relative_path_components,
330 &mut file_sink,
331 )?,
332 GameKind::Vx => self::vx::compile(
333 entry_path,
334 entry_file_type,
335 relative_path,
336 relative_path_components,
337 &mut file_sink,
338 )?,
339 GameKind::VxAce => self::vx_ace::compile(
340 entry_path,
341 entry_file_type,
342 relative_path,
343 relative_path_components,
344 &mut file_sink,
345 )?,
346 }
347 }
348
349 file_sink.finish()?;
350
351 Ok(())
352}