rpgmxp_tool/commands/
extract_assets.rs

1mod file_entry_iter;
2
3use self::file_entry_iter::FileEntry;
4use self::file_entry_iter::FileEntryIter;
5use crate::util::ArrayLikeElement;
6use crate::GameKind;
7use anyhow::bail;
8use anyhow::ensure;
9use anyhow::Context;
10use camino::Utf8Path;
11use rpgmxp_types::Actor;
12use rpgmxp_types::Animation;
13use rpgmxp_types::Armor;
14use rpgmxp_types::Class;
15use rpgmxp_types::CommonEvent;
16use rpgmxp_types::Enemy;
17use rpgmxp_types::Item;
18use rpgmxp_types::Skill;
19use rpgmxp_types::State;
20use rpgmxp_types::Tileset;
21use rpgmxp_types::Troop;
22use rpgmxp_types::Weapon;
23use ruby_marshal::FromValueContext;
24use std::collections::BTreeMap;
25use std::fs::File;
26use std::io::Write;
27use std::path::Path;
28use std::path::PathBuf;
29
30#[derive(Debug, argh::FromArgs)]
31#[argh(
32    subcommand,
33    name = "extract-assets",
34    description = "extract the assets from a game into a format that is modifiable"
35)]
36pub struct Options {
37    #[argh(
38        positional,
39        description = "the path to the game folder or rgssad archive"
40    )]
41    pub input: PathBuf,
42
43    #[argh(positional, description = "the folder to extract-assets to")]
44    pub output: PathBuf,
45
46    #[argh(
47        switch,
48        long = "overwrite",
49        description = "whether overwrite the output directory"
50    )]
51    pub overwrite: bool,
52
53    #[argh(
54        switch,
55        long = "skip-extract-scripts",
56        description = "whether scripts should not be extracted"
57    )]
58    pub skip_extract_scripts: bool,
59
60    #[argh(
61        switch,
62        long = "skip-extract-common-events",
63        description = "whether common events should not be extracted"
64    )]
65    pub skip_extract_common_events: bool,
66
67    #[argh(
68        switch,
69        long = "skip-extract-system",
70        description = "whether system data should not be extracted"
71    )]
72    pub skip_extract_system: bool,
73
74    #[argh(
75        switch,
76        long = "skip-extract-actors",
77        description = "whether actors should not be extracted"
78    )]
79    pub skip_extract_actors: bool,
80
81    #[argh(
82        switch,
83        long = "skip-extract-weapons",
84        description = "whether weapons should not be extracted"
85    )]
86    pub skip_extract_weapons: bool,
87
88    #[argh(
89        switch,
90        long = "skip-extract-armors",
91        description = "whether armor should not be extracted"
92    )]
93    pub skip_extract_armors: bool,
94
95    #[argh(
96        switch,
97        long = "skip-extract-skills",
98        description = "whether skills should not be extracted"
99    )]
100    pub skip_extract_skills: bool,
101
102    #[argh(
103        switch,
104        long = "skip-extract-states",
105        description = "whether states should not be extracted"
106    )]
107    pub skip_extract_states: bool,
108
109    #[argh(
110        switch,
111        long = "skip-extract-items",
112        description = "whether items should not be extracted"
113    )]
114    pub skip_extract_items: bool,
115
116    #[argh(
117        switch,
118        long = "skip-extract-enemies",
119        description = "whether enemies should not be extracted"
120    )]
121    pub skip_extract_enemies: bool,
122
123    #[argh(
124        switch,
125        long = "skip-extract-classes",
126        description = "whether classes should not be extracted"
127    )]
128    pub skip_extract_classes: bool,
129
130    #[argh(
131        switch,
132        long = "skip-extract-troops",
133        description = "whether troops should not be extracted"
134    )]
135    pub skip_extract_troops: bool,
136
137    #[argh(
138        switch,
139        long = "skip-extract-tilesets",
140        description = "whether tilesets should not be extracted"
141    )]
142    pub skip_extract_tilesets: bool,
143
144    #[argh(
145        switch,
146        long = "skip-extract-map-infos",
147        description = "whether map infos should not be extracted"
148    )]
149    pub skip_extract_map_infos: bool,
150
151    #[argh(
152        switch,
153        long = "skip-extract-animations",
154        description = "whether animations should not be extracted"
155    )]
156    pub skip_extract_animations: bool,
157
158    #[argh(
159        switch,
160        long = "skip-extract-maps",
161        description = "whether maps should not be extracted"
162    )]
163    pub skip_extract_maps: bool,
164}
165
166pub fn exec(mut options: Options) -> anyhow::Result<()> {
167    options.input = options
168        .input
169        .canonicalize()
170        .context("failed to canonicalize input path")?;
171
172    if options.output.try_exists()? {
173        if options.overwrite {
174            std::fs::remove_dir_all(&options.output)?;
175        } else {
176            bail!("output path exists");
177        }
178    }
179
180    // TODO: Should we validate the output in some way?
181    // Prevent writing to the input dir?
182    std::fs::create_dir_all(&options.output)?;
183
184    options.output = options
185        .output
186        .canonicalize()
187        .context("failed to canonicalize output path")?;
188
189    let mut file_entry_iter = FileEntryIter::new(&options.input)?;
190    let game_kind = file_entry_iter.game_kind();
191
192    while let Some(mut entry) = file_entry_iter.next_file_entry()? {
193        let raw_relative_path = entry.relative_path().to_path_buf();
194        let relative_path_components = parse_relative_path(&raw_relative_path)?;
195        let relative_path_display = relative_path_components.join("/");
196        let output_path = {
197            let mut output_path = options.output.clone();
198            output_path.extend(relative_path_components.clone());
199            output_path
200        };
201
202        println!("extracting \"{relative_path_display}\"");
203
204        if let Some(parent) = output_path.parent() {
205            std::fs::create_dir_all(parent)
206                .with_context(|| format!("failed to create dir at \"{}\"", parent.display()))?;
207        }
208
209        match game_kind {
210            GameKind::Xp => {
211                extract_xp(&options, &mut entry, relative_path_components, output_path)?
212            }
213            GameKind::Vx => {
214                extract_vx(&options, &mut entry, relative_path_components, output_path)?
215            }
216        }
217    }
218
219    Ok(())
220}
221
222fn extract_xp(
223    options: &Options,
224    entry: &mut FileEntry<'_>,
225    relative_path_components: Vec<&str>,
226    output_path: PathBuf,
227) -> anyhow::Result<()> {
228    match relative_path_components.as_slice() {
229        ["Data", "Scripts.rxdata"] if !options.skip_extract_scripts => {
230            extract_scripts(entry, output_path)?;
231        }
232        ["Data", "CommonEvents.rxdata"] if !options.skip_extract_common_events => {
233            extract_arraylike::<CommonEvent>(entry, output_path)?;
234        }
235        ["Data", "Actors.rxdata"] if !options.skip_extract_actors => {
236            extract_arraylike::<Actor>(entry, output_path)?;
237        }
238        ["Data", "Weapons.rxdata"] if !options.skip_extract_weapons => {
239            extract_arraylike::<Weapon>(entry, output_path)?;
240        }
241        ["Data", "Armors.rxdata"] if !options.skip_extract_armors => {
242            extract_arraylike::<Armor>(entry, output_path)?;
243        }
244        ["Data", "Skills.rxdata"] if !options.skip_extract_skills => {
245            extract_arraylike::<Skill>(entry, output_path)?;
246        }
247        ["Data", "States.rxdata"] if !options.skip_extract_states => {
248            extract_arraylike::<State>(entry, output_path)?;
249        }
250        ["Data", "Items.rxdata"] if !options.skip_extract_items => {
251            extract_arraylike::<Item>(entry, output_path)?;
252        }
253        ["Data", "Enemies.rxdata"] if !options.skip_extract_enemies => {
254            extract_arraylike::<Enemy>(entry, output_path)?;
255        }
256        ["Data", "Classes.rxdata"] if !options.skip_extract_classes => {
257            extract_arraylike::<Class>(entry, output_path)?;
258        }
259        ["Data", "Troops.rxdata"] if !options.skip_extract_troops => {
260            extract_arraylike::<Troop>(entry, output_path)?;
261        }
262        ["Data", "Tilesets.rxdata"] if !options.skip_extract_tilesets => {
263            extract_arraylike::<Tileset>(entry, output_path)?;
264        }
265        ["Data", "MapInfos.rxdata"] if !options.skip_extract_map_infos => {
266            extract_map_infos(entry, output_path)?;
267        }
268        ["Data", "System.rxdata"] if !options.skip_extract_system => {
269            extract_ruby_data::<rpgmxp_types::System>(entry, output_path)?;
270        }
271        ["Data", "Animations.rxdata"] if !options.skip_extract_animations => {
272            extract_arraylike::<Animation>(entry, output_path)?;
273        }
274        ["Data", file]
275            if !options.skip_extract_maps && crate::util::is_map_file_name(file, "rxdata") =>
276        {
277            extract_ruby_data::<rpgmxp_types::Map>(entry, output_path)?;
278        }
279        _ => {
280            let temp_path = nd_util::with_push_extension(&output_path, "temp");
281            // TODO: Lock?
282            // TODO: Drop delete guard for file?
283            let mut output_file = File::create(&temp_path)
284                .with_context(|| format!("failed to open file at \"{}\"", output_path.display()))?;
285
286            std::io::copy(entry, &mut output_file)?;
287            std::fs::rename(&temp_path, &output_path)?;
288        }
289    }
290
291    Ok(())
292}
293
294fn extract_vx(
295    options: &Options,
296    entry: &mut FileEntry<'_>,
297    relative_path_components: Vec<&str>,
298    output_path: PathBuf,
299) -> anyhow::Result<()> {
300    match relative_path_components.as_slice() {
301        ["Data", "Scripts.rvdata"] if !options.skip_extract_scripts => {
302            extract_scripts(entry, output_path)?;
303        }
304        ["Data", "MapInfos.rvdata"] if !options.skip_extract_map_infos => {
305            extract_map_infos(entry, output_path)?;
306        }
307        ["Data", "System.rvdata"] if !options.skip_extract_system => {
308            extract_ruby_data::<rpgmvx_types::System>(entry, output_path)?;
309        }
310        ["Data", file]
311            if !options.skip_extract_maps && crate::util::is_map_file_name(file, "rvdata") =>
312        {
313            extract_ruby_data::<rpgmvx_types::Map>(entry, output_path)?;
314        }
315        _ => {
316            let temp_path = nd_util::with_push_extension(&output_path, "temp");
317            // TODO: Lock?
318            // TODO: Drop delete guard for file?
319            let mut output_file = File::create(&temp_path)
320                .with_context(|| format!("failed to open file at \"{}\"", output_path.display()))?;
321
322            std::io::copy(entry, &mut output_file)?;
323            std::fs::rename(&temp_path, &output_path)?;
324        }
325    }
326
327    Ok(())
328}
329
330fn parse_relative_path(path: &Utf8Path) -> anyhow::Result<Vec<&str>> {
331    let mut components = Vec::with_capacity(4);
332
333    // There is a lot of problems with using proper path parsing here.
334    //
335    // Since we need to accept both Windows and Unix paths here on any host,
336    // since we may be running on Unix and rgssad archives use Windows paths.
337    // This means we cannot use std's paths.
338    //
339    // We need to ensure that the given paths do not have hostile components,
340    // like .. or C:. This is because rgssad archives are user supplied.
341    // This means we still need to parse the paths somehow.
342    //
343    // The `typed-paths` crate seems ideal superficially,
344    // but path conversions can easily cause paths with prefixes to replace the root.
345    //
346    // As a result, we need to do our own parsing here and be as conservative as possible.
347
348    for component in path.as_str().split(['/', '\\']) {
349        ensure!(!component.is_empty());
350        ensure!(component != "..");
351        ensure!(!component.contains(':'));
352
353        if component == "." {
354            continue;
355        }
356
357        components.push(component);
358    }
359
360    Ok(components)
361}
362
363fn extract_scripts<P>(file: impl std::io::Read, dir_path: P) -> anyhow::Result<()>
364where
365    P: AsRef<Path>,
366{
367    let dir_path = dir_path.as_ref();
368    let temp_dir_path = nd_util::with_push_extension(dir_path, "temp");
369
370    // TODO: Lock?
371    // TODO: Drop delete guard for file?
372    std::fs::create_dir_all(&temp_dir_path)?;
373
374    let arena = ruby_marshal::load(file)?;
375    let ctx = FromValueContext::new(&arena);
376    let script_list: rpgm_common_types::ScriptList = ctx.from_value(arena.root())?;
377
378    for (script_index, script) in script_list.scripts.iter().enumerate() {
379        println!("  extracting script \"{}\"", script.name);
380
381        let escaped_script_name = crate::util::percent_escape_file_name(&script.name);
382
383        let out_path = temp_dir_path.join(format!("{script_index:03}-{escaped_script_name}.rb"));
384        let temp_path = nd_util::with_push_extension(&out_path, "temp");
385
386        // TODO: Lock?
387        // TODO: Drop delete guard for file?
388        std::fs::write(&temp_path, &script.data)?;
389        std::fs::rename(temp_path, out_path)?;
390    }
391
392    std::fs::rename(temp_dir_path, dir_path)?;
393
394    Ok(())
395}
396
397fn extract_arraylike<T>(file: impl std::io::Read, dir_path: impl AsRef<Path>) -> anyhow::Result<()>
398where
399    T: for<'a> ArrayLikeElement<'a>,
400{
401    let dir_path = dir_path.as_ref();
402    let type_display_name = T::type_display_name();
403
404    std::fs::create_dir_all(dir_path)?;
405
406    let arena = ruby_marshal::load(file)?;
407    let ctx = FromValueContext::new(&arena);
408    let array: Vec<Option<T>> = ctx.from_value(arena.root())?;
409
410    for (index, value) in array.iter().enumerate() {
411        if index == 0 {
412            ensure!(value.is_none(), "{type_display_name} 0 should be nil");
413            continue;
414        }
415
416        let value = value
417            .as_ref()
418            .with_context(|| format!("{type_display_name} is nil"))?;
419
420        println!("  extracting {} \"{}\"", type_display_name, value.name());
421
422        let name = value.name();
423        let file_name = format!("{index:03}-{name}.json");
424        let file_name = crate::util::percent_escape_file_name(file_name.as_str());
425        let out_path = dir_path.join(file_name);
426        let temp_path = nd_util::with_push_extension(&out_path, "temp");
427
428        // TODO: Lock?
429        // TODO: Drop delete guard for file?
430        let mut output_file = File::create_new(&temp_path)?;
431        serde_json::to_writer_pretty(&mut output_file, value)?;
432        output_file.flush()?;
433        output_file.sync_all()?;
434        drop(output_file);
435
436        std::fs::rename(temp_path, out_path)?;
437    }
438
439    Ok(())
440}
441
442fn extract_map_infos<P>(file: impl std::io::Read, dir_path: P) -> anyhow::Result<()>
443where
444    P: AsRef<Path>,
445{
446    let dir_path = dir_path.as_ref();
447
448    std::fs::create_dir_all(dir_path)?;
449
450    let arena = ruby_marshal::load(file)?;
451    let ctx = FromValueContext::new(&arena);
452    let map: BTreeMap<i32, rpgm_common_types::MapInfo> = ctx.from_value(arena.root())?;
453
454    for (index, value) in map.iter() {
455        let name = value.name.as_str();
456
457        println!("  extracting map info \"{name}\"");
458
459        let out_path = dir_path.join(format!("{index:03}-{name}.json"));
460        let temp_path = nd_util::with_push_extension(&out_path, "temp");
461
462        // TODO: Lock?
463        // TODO: Drop delete guard for file?
464        let mut output_file = File::create_new(&temp_path)?;
465        serde_json::to_writer_pretty(&mut output_file, value)?;
466        output_file.flush()?;
467        output_file.sync_all()?;
468        drop(output_file);
469
470        std::fs::rename(temp_path, out_path)?;
471    }
472
473    Ok(())
474}
475
476fn extract_ruby_data<T>(file: impl std::io::Read, path: impl AsRef<Path>) -> anyhow::Result<()>
477where
478    T: serde::Serialize + for<'a> ruby_marshal::FromValue<'a>,
479{
480    let path = path.as_ref();
481    let path = path.with_extension("json");
482
483    let arena = ruby_marshal::load(file)?;
484    let ctx = FromValueContext::new(&arena);
485    let data: T = ctx.from_value(arena.root())?;
486
487    // TODO: Lock?
488    // TODO: Drop delete guard for file?
489    let temp_path = nd_util::with_push_extension(&path, "temp");
490    let mut file = File::create_new(&temp_path)?;
491    serde_json::to_writer_pretty(&mut file, &data)?;
492    file.flush()?;
493    file.sync_all()?;
494    std::fs::rename(temp_path, path)?;
495
496    Ok(())
497}