rpgmxp_tool/commands/
compile_assets.rs

1mod 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    // TODO: Consider enforcing that script index ranges cannot have holes and must start at 0.
112    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    // TODO: Consider enforcing that value index ranges cannot have holes and must start at 1.
173    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}