rpgmxp_tool/commands/
extract_assets.rs

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