rpgmxp_tool/commands/
compile_assets.rs

1mod file_sink;
2
3use self::file_sink::FileSink;
4use crate::util::ArrayLikeElement;
5use crate::GameKind;
6use anyhow::bail;
7use anyhow::ensure;
8use anyhow::Context;
9use rpgmxp_types::Actor;
10use rpgmxp_types::Animation;
11use rpgmxp_types::Armor;
12use rpgmxp_types::Class;
13use rpgmxp_types::CommonEvent;
14use rpgmxp_types::Enemy;
15use rpgmxp_types::Item;
16use rpgmxp_types::Script;
17use rpgmxp_types::ScriptList;
18use rpgmxp_types::Skill;
19use rpgmxp_types::State;
20use rpgmxp_types::Tileset;
21use rpgmxp_types::Troop;
22use rpgmxp_types::Weapon;
23use ruby_marshal::IntoValue;
24use std::collections::BTreeMap;
25use std::fs::File;
26use std::path::Component as PathComponent;
27use std::path::Path;
28use std::path::PathBuf;
29use std::str::FromStr;
30use walkdir::WalkDir;
31
32#[derive(Debug)]
33enum Format {
34    Dir,
35    Rgssad,
36    Rgss2a,
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            _ => bail!("unknown format \"{input}\""),
48        }
49    }
50}
51
52#[derive(Debug, argh::FromArgs)]
53#[argh(
54    subcommand,
55    name = "compile-assets",
56    description = "recompile extracted assets from a folder"
57)]
58pub struct Options {
59    #[argh(positional, description = "the input folder path to compile")]
60    input: PathBuf,
61
62    #[argh(positional, description = "the output path")]
63    output: PathBuf,
64
65    #[argh(
66        option,
67        long = "format",
68        short = 'f',
69        description = "the output format. Defaults to detecting from the extension. Otherwise, \"dir\" is used."
70    )]
71    format: Option<Format>,
72
73    #[argh(
74        option,
75        long = "game",
76        short = 'g',
77        description = "the game type. Defaults to detecting from the output format. Must be provided if the output format is a dir."
78    )]
79    game: Option<GameKind>,
80
81    #[argh(
82        switch,
83        long = "overwrite",
84        description = "whether overwrite the output if it exists"
85    )]
86    pub overwrite: bool,
87}
88
89pub fn exec(mut options: Options) -> anyhow::Result<()> {
90    options.input = options
91        .input
92        .canonicalize()
93        .context("failed to canonicalize input path")?;
94
95    let format = match options.format {
96        Some(format) => format,
97        None => {
98            let extension = options
99                .output
100                .extension()
101                .map(|extension| extension.to_str().context("non-unicode extension"))
102                .transpose()?;
103            if extension == Some("rgssad") {
104                Format::Rgssad
105            } else if extension == Some("rgss2a") {
106                Format::Rgss2a
107            } else {
108                Format::Dir
109            }
110        }
111    };
112
113    let mut file_sink = match format {
114        Format::Dir => FileSink::new_dir(&options.output, options.overwrite)?,
115        Format::Rgssad | Format::Rgss2a => {
116            FileSink::new_rgssad(&options.output, options.overwrite)?
117        }
118    };
119    let game_kind = options.game.map(Ok).unwrap_or_else(|| match format {
120        Format::Dir => {
121            bail!("need to provide game type with --game flag when outputting to a dir.")
122        }
123        Format::Rgssad => Ok(GameKind::Xp),
124        Format::Rgss2a => Ok(GameKind::Vx),
125    })?;
126
127    for entry in WalkDir::new(&options.input) {
128        let entry = entry?;
129        let entry_file_type = entry.file_type();
130        let entry_path = entry.path();
131
132        let relative_path = entry_path.strip_prefix(&options.input)?;
133        let relative_path_components = relative_path
134            .components()
135            .map(|component| match component {
136                PathComponent::Normal(value) => value.to_str().context("non-unicode path"),
137                component => bail!("unexpected path component \"{component:?}\""),
138            })
139            .collect::<anyhow::Result<Vec<_>>>()?;
140
141        match game_kind {
142            GameKind::Xp => compile_xp(
143                entry_path,
144                entry_file_type,
145                relative_path,
146                relative_path_components,
147                &mut file_sink,
148            )?,
149            GameKind::Vx => compile_vx(
150                entry_path,
151                entry_file_type,
152                relative_path,
153                relative_path_components,
154                &mut file_sink,
155            )?,
156        }
157    }
158
159    file_sink.finish()?;
160
161    Ok(())
162}
163
164fn compile_xp(
165    entry_path: &Path,
166    entry_file_type: std::fs::FileType,
167    relative_path: &Path,
168    relative_path_components: Vec<&str>,
169    file_sink: &mut FileSink,
170) -> anyhow::Result<()> {
171    match relative_path_components.as_slice() {
172        ["Data", "Scripts.rxdata"] if entry_file_type.is_dir() => {
173            println!("packing \"{}\"", relative_path.display());
174
175            let scripts_data = generate_scripts_data(entry_path)?;
176            let size = u32::try_from(scripts_data.len())?;
177
178            file_sink.write_file(&relative_path_components, size, &*scripts_data)?;
179        }
180        ["Data", "Scripts.rxdata", ..] => {
181            // Ignore entries, we explore them in the above branch.
182        }
183        ["Data", "CommonEvents.rxdata"] if entry_file_type.is_dir() => {
184            println!("packing \"{}\"", relative_path.display());
185
186            let rx_data = generate_arraylike_rx_data::<CommonEvent>(entry_path)?;
187            let size = u32::try_from(rx_data.len())?;
188
189            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
190        }
191        ["Data", "CommonEvents.rxdata", ..] => {
192            // Ignore entries, we explore them in the above branch.
193        }
194        ["Data", "Actors.rxdata"] if entry_file_type.is_dir() => {
195            println!("packing \"{}\"", relative_path.display());
196
197            let rx_data = generate_arraylike_rx_data::<Actor>(entry_path)?;
198            let size = u32::try_from(rx_data.len())?;
199
200            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
201        }
202        ["Data", "Actors.rxdata", ..] => {
203            // Ignore entries, we explore them in the above branch.
204        }
205        ["Data", "Weapons.rxdata"] if entry_file_type.is_dir() => {
206            println!("packing \"{}\"", relative_path.display());
207
208            let rx_data = generate_arraylike_rx_data::<Weapon>(entry_path)?;
209            let size = u32::try_from(rx_data.len())?;
210
211            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
212        }
213        ["Data", "Weapons.rxdata", ..] => {
214            // Ignore entries, we explore them in the above branch.
215        }
216        ["Data", "Armors.rxdata"] if entry_file_type.is_dir() => {
217            println!("packing \"{}\"", relative_path.display());
218
219            let rx_data = generate_arraylike_rx_data::<Armor>(entry_path)?;
220            let size = u32::try_from(rx_data.len())?;
221
222            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
223        }
224        ["Data", "Armors.rxdata", ..] => {
225            // Ignore entries, we explore them in the above branch.
226        }
227        ["Data", "Skills.rxdata"] if entry_file_type.is_dir() => {
228            println!("packing \"{}\"", relative_path.display());
229
230            let rx_data = generate_arraylike_rx_data::<Skill>(entry_path)?;
231            let size = u32::try_from(rx_data.len())?;
232
233            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
234        }
235        ["Data", "Skills.rxdata", ..] => {
236            // Ignore entries, we explore them in the above branch.
237        }
238        ["Data", "States.rxdata"] if entry_file_type.is_dir() => {
239            println!("packing \"{}\"", relative_path.display());
240
241            let rx_data = generate_arraylike_rx_data::<State>(entry_path)?;
242            let size = u32::try_from(rx_data.len())?;
243
244            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
245        }
246        ["Data", "States.rxdata", ..] => {
247            // Ignore entries, we explore them in the above branch.
248        }
249        ["Data", "Items.rxdata"] if entry_file_type.is_dir() => {
250            println!("packing \"{}\"", relative_path.display());
251
252            let rx_data = generate_arraylike_rx_data::<Item>(entry_path)?;
253            let size = u32::try_from(rx_data.len())?;
254
255            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
256        }
257        ["Data", "Items.rxdata", ..] => {
258            // Ignore entries, we explore them in the above branch.
259        }
260        ["Data", "Enemies.rxdata"] if entry_file_type.is_dir() => {
261            println!("packing \"{}\"", relative_path.display());
262
263            let rx_data = generate_arraylike_rx_data::<Enemy>(entry_path)?;
264            let size = u32::try_from(rx_data.len())?;
265
266            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
267        }
268        ["Data", "Enemies.rxdata", ..] => {
269            // Ignore entries, we explore them in the above branch.
270        }
271        ["Data", "Classes.rxdata"] if entry_file_type.is_dir() => {
272            println!("packing \"{}\"", relative_path.display());
273
274            let rx_data = generate_arraylike_rx_data::<Class>(entry_path)?;
275            let size = u32::try_from(rx_data.len())?;
276
277            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
278        }
279        ["Data", "Classes.rxdata", ..] => {
280            // Ignore entries, we explore them in the above branch.
281        }
282        ["Data", "Troops.rxdata"] if entry_file_type.is_dir() => {
283            println!("packing \"{}\"", relative_path.display());
284
285            let rx_data = generate_arraylike_rx_data::<Troop>(entry_path)?;
286            let size = u32::try_from(rx_data.len())?;
287
288            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
289        }
290        ["Data", "Troops.rxdata", ..] => {
291            // Ignore entries, we explore them in the above branch.
292        }
293        ["Data", "Tilesets.rxdata"] if entry_file_type.is_dir() => {
294            println!("packing \"{}\"", relative_path.display());
295
296            let rx_data = generate_arraylike_rx_data::<Tileset>(entry_path)?;
297            let size = u32::try_from(rx_data.len())?;
298
299            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
300        }
301        ["Data", "Tilesets.rxdata", ..] => {
302            // Ignore entries, we explore them in the above branch.
303        }
304        ["Data", "MapInfos.rxdata"] if entry_file_type.is_dir() => {
305            println!("packing \"{}\"", relative_path.display());
306
307            let rx_data = generate_map_infos_data(entry_path)?;
308            let size = u32::try_from(rx_data.len())?;
309
310            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
311        }
312        ["Data", "MapInfos.rxdata", ..] => {
313            // Ignore entries, we explore them in the above branch.
314        }
315        ["Data", "System.json"] if entry_file_type.is_file() => {
316            println!("packing \"{}\"", relative_path.display());
317
318            let data = generate_ruby_data::<rpgmxp_types::System>(entry_path)?;
319            let size = u32::try_from(data.len())?;
320
321            let mut relative_path_components = relative_path_components.clone();
322            *relative_path_components.last_mut().unwrap() = "System.rxdata";
323
324            file_sink.write_file(&relative_path_components, size, &*data)?;
325        }
326        ["Data", "Animations.rxdata"] if entry_file_type.is_dir() => {
327            println!("packing \"{}\"", relative_path.display());
328
329            let rx_data = generate_arraylike_rx_data::<Animation>(entry_path)?;
330            let size = u32::try_from(rx_data.len())?;
331
332            file_sink.write_file(&relative_path_components, size, &*rx_data)?;
333        }
334        ["Data", "Animations.rxdata", ..] => {
335            // Ignore entries, we explore them in the above branch.
336        }
337        ["Data", file] if crate::util::is_map_file_name(file, "json") => {
338            println!("packing \"{}\"", relative_path.display());
339
340            let map_data = generate_ruby_data::<rpgmxp_types::Map>(entry_path)?;
341            let size = u32::try_from(map_data.len())?;
342
343            let renamed_file = set_extension_str(file, "rxdata");
344            let mut relative_path_components = relative_path_components.clone();
345            *relative_path_components.last_mut().unwrap() = renamed_file.as_str();
346
347            file_sink.write_file(&relative_path_components, size, &*map_data)?;
348        }
349        relative_path_components if entry_file_type.is_file() => {
350            // Copy file by default
351            println!("packing \"{}\"", relative_path.display());
352
353            let input_file = File::open(entry_path).with_context(|| {
354                format!(
355                    "failed to open input file from \"{}\"",
356                    entry_path.display()
357                )
358            })?;
359            let metadata = input_file.metadata()?;
360            let size = u32::try_from(metadata.len())?;
361
362            file_sink.write_file(relative_path_components, size, input_file)?;
363        }
364        _ => {}
365    }
366
367    Ok(())
368}
369
370fn compile_vx(
371    entry_path: &Path,
372    entry_file_type: std::fs::FileType,
373    relative_path: &Path,
374    relative_path_components: Vec<&str>,
375    file_sink: &mut FileSink,
376) -> anyhow::Result<()> {
377    match relative_path_components.as_slice() {
378        ["Data", "Scripts.rvdata"] if entry_file_type.is_dir() => {
379            println!("packing \"{}\"", relative_path.display());
380
381            let scripts_data = generate_scripts_data(entry_path)?;
382            let size = u32::try_from(scripts_data.len())?;
383
384            file_sink.write_file(&relative_path_components, size, &*scripts_data)?;
385        }
386        ["Data", "Scripts.rvdata", ..] => {
387            // Ignore entries, we explore them in the above branch.
388        }
389        ["Data", "MapInfos.rvdata"] if entry_file_type.is_dir() => {
390            println!("packing \"{}\"", relative_path.display());
391
392            let data = generate_map_infos_data(entry_path)?;
393            let size = u32::try_from(data.len())?;
394
395            file_sink.write_file(&relative_path_components, size, &*data)?;
396        }
397        ["Data", "MapInfos.rvdata", ..] => {
398            // Ignore entries, we explore them in the above branch.
399        }
400        ["Data", "System.json"] if entry_file_type.is_file() => {
401            println!("packing \"{}\"", relative_path.display());
402
403            let data = generate_ruby_data::<rpgmvx_types::System>(entry_path)?;
404            let size = u32::try_from(data.len())?;
405
406            let mut relative_path_components = relative_path_components.clone();
407            *relative_path_components.last_mut().unwrap() = "System.rvdata";
408
409            file_sink.write_file(&relative_path_components, size, &*data)?;
410        }
411        ["Data", file] if crate::util::is_map_file_name(file, "json") => {
412            println!("packing \"{}\"", relative_path.display());
413
414            let map_data = generate_ruby_data::<rpgmvx_types::Map>(entry_path)?;
415            let size = u32::try_from(map_data.len())?;
416
417            let renamed_file = set_extension_str(file, "rvdata");
418            let mut relative_path_components = relative_path_components.clone();
419            *relative_path_components.last_mut().unwrap() = renamed_file.as_str();
420
421            file_sink.write_file(&relative_path_components, size, &*map_data)?;
422        }
423        relative_path_components if entry_file_type.is_file() => {
424            // Copy file by default
425            println!("packing \"{}\"", relative_path.display());
426
427            let input_file = File::open(entry_path).with_context(|| {
428                format!(
429                    "failed to open input file from \"{}\"",
430                    entry_path.display()
431                )
432            })?;
433            let metadata = input_file.metadata()?;
434            let size = u32::try_from(metadata.len())?;
435
436            file_sink.write_file(relative_path_components, size, input_file)?;
437        }
438        _ => {}
439    }
440
441    Ok(())
442}
443
444fn set_extension_str(input: &str, extension: &str) -> String {
445    let stem = input
446        .rsplit_once('.')
447        .map(|(stem, _extension)| stem)
448        .unwrap_or(input);
449
450    format!("{stem}.{extension}")
451}
452
453fn generate_scripts_data(path: &Path) -> anyhow::Result<Vec<u8>> {
454    let mut scripts_map = BTreeMap::new();
455
456    for dir_entry in path.read_dir()? {
457        let dir_entry = dir_entry?;
458        let dir_entry_file_type = dir_entry.file_type()?;
459
460        ensure!(dir_entry_file_type.is_file());
461
462        let dir_entry_file_name = dir_entry.file_name();
463        let dir_entry_file_name = dir_entry_file_name
464            .to_str()
465            .context("non-unicode script name")?;
466        let dir_entry_file_stem = dir_entry_file_name
467            .strip_suffix(".rb")
468            .context("script is not an \"rb\" file")?;
469
470        let (script_index, escaped_script_name) = dir_entry_file_stem
471            .split_once('-')
472            .context("invalid script name format")?;
473        let script_index: usize = script_index.parse()?;
474        let unescaped_file_name = crate::util::percent_unescape_file_name(escaped_script_name)?;
475
476        println!("  packing script \"{escaped_script_name}\"");
477
478        let dir_entry_path = dir_entry.path();
479        let script_data = std::fs::read_to_string(dir_entry_path)?;
480
481        let old_entry = scripts_map.insert(
482            script_index,
483            Script {
484                data: script_data,
485                id: i32::try_from(script_index)? + 1,
486                name: unescaped_file_name,
487            },
488        );
489        if old_entry.is_some() {
490            bail!("duplicate scripts for index {script_index}");
491        }
492    }
493
494    // TODO: Consider enforcing that script index ranges cannot have holes and must start at 0.
495    let script_list = ScriptList {
496        scripts: scripts_map.into_values().collect(),
497    };
498
499    let mut arena = ruby_marshal::ValueArena::new();
500    let handle = script_list.into_value(&mut arena)?;
501    arena.replace_root(handle);
502
503    let mut data = Vec::new();
504    ruby_marshal::dump(&mut data, &arena)?;
505
506    Ok(data)
507}
508
509fn generate_arraylike_rx_data<T>(path: &Path) -> anyhow::Result<Vec<u8>>
510where
511    T: for<'a> ArrayLikeElement<'a>,
512{
513    fn load_json_str(
514        dir_entry: std::io::Result<std::fs::DirEntry>,
515        type_display_name: &str,
516    ) -> anyhow::Result<(usize, String)> {
517        let dir_entry = dir_entry?;
518        let dir_entry_file_type = dir_entry.file_type()?;
519
520        ensure!(dir_entry_file_type.is_file());
521
522        let dir_entry_file_name = dir_entry.file_name();
523        let dir_entry_file_name = dir_entry_file_name.to_str().context("non-unicode name")?;
524        let dir_entry_file_stem = dir_entry_file_name
525            .strip_suffix(".json")
526            .context("not a \"json\" file")?;
527
528        let (index, name) = dir_entry_file_stem
529            .split_once('-')
530            .context("invalid name format")?;
531        let name = crate::util::percent_unescape_file_name(name)?;
532        let index: usize = index.parse()?;
533
534        println!("  packing {type_display_name} \"{name}\"");
535
536        let dir_entry_path = dir_entry.path();
537        let json = std::fs::read_to_string(dir_entry_path)?;
538
539        Ok((index, json))
540    }
541
542    let type_display_name = T::type_display_name();
543    let mut map: BTreeMap<usize, T> = BTreeMap::new();
544
545    for dir_entry in path.read_dir()? {
546        let (index, json) = load_json_str(dir_entry, type_display_name)?;
547        let value: T = serde_json::from_str(&json)?;
548
549        let old_entry = map.insert(index, value);
550        if old_entry.is_some() {
551            bail!("duplicate {type_display_name} for index {index}");
552        }
553    }
554
555    // TODO: Consider enforcing that value index ranges cannot have holes and must start at 1.
556    let mut data = Vec::with_capacity(map.len() + 1);
557    data.push(None);
558    for value in map.into_values() {
559        data.push(Some(value));
560    }
561
562    let mut arena = ruby_marshal::ValueArena::new();
563    let handle = data.into_value(&mut arena)?;
564    arena.replace_root(handle);
565
566    let mut data = Vec::new();
567    ruby_marshal::dump(&mut data, &arena)?;
568
569    Ok(data)
570}
571
572fn generate_map_infos_data(path: &Path) -> anyhow::Result<Vec<u8>> {
573    let mut map: BTreeMap<i32, rpgm_common_types::MapInfo> = BTreeMap::new();
574
575    for dir_entry in path.read_dir()? {
576        let dir_entry = dir_entry?;
577        let dir_entry_file_type = dir_entry.file_type()?;
578
579        ensure!(dir_entry_file_type.is_file());
580
581        let dir_entry_file_name = dir_entry.file_name();
582        let dir_entry_file_name = dir_entry_file_name.to_str().context("non-unicode name")?;
583        let dir_entry_file_stem = dir_entry_file_name
584            .strip_suffix(".json")
585            .context("not a \"json\" file")?;
586
587        let (index, name) = dir_entry_file_stem
588            .split_once('-')
589            .context("invalid name format")?;
590        let index: i32 = index.parse()?;
591
592        println!("  packing map info \"{name}\"");
593
594        let dir_entry_path = dir_entry.path();
595        let json = std::fs::read_to_string(dir_entry_path)?;
596
597        let value: rpgm_common_types::MapInfo = serde_json::from_str(&json)?;
598
599        let old_entry = map.insert(index, value);
600        if old_entry.is_some() {
601            bail!("duplicate map info for index {index}");
602        }
603    }
604
605    let mut arena = ruby_marshal::ValueArena::new();
606    let handle = map.into_value(&mut arena)?;
607    arena.replace_root(handle);
608
609    let mut data = Vec::new();
610    ruby_marshal::dump(&mut data, &arena)?;
611
612    Ok(data)
613}
614
615fn generate_ruby_data<T>(path: &Path) -> anyhow::Result<Vec<u8>>
616where
617    T: serde::de::DeserializeOwned + ruby_marshal::IntoValue,
618{
619    let map = std::fs::read_to_string(path)?;
620    let map: T = serde_json::from_str(&map)?;
621
622    let mut arena = ruby_marshal::ValueArena::new();
623    let handle = map.into_value(&mut arena)?;
624    arena.replace_root(handle);
625
626    let mut data = Vec::new();
627    ruby_marshal::dump(&mut data, &arena)?;
628
629    Ok(data)
630}