rpgmv_tool/command/
commands2py.rs

1mod command;
2mod config;
3mod file_sink;
4mod generate;
5
6use self::command::Command;
7use self::command::ConditionalBranchCommand;
8use self::command::ControlVariablesValue;
9use self::command::ControlVariablesValueGameData;
10use self::command::GetLocationInfoKind;
11use self::command::MaybeRef;
12use self::command::parse_event_command_list;
13use self::config::Config;
14use self::file_sink::FileSink;
15use self::generate::commands2py;
16use anyhow::Context;
17use anyhow::bail;
18use anyhow::ensure;
19use clap::Parser;
20use std::path::Path;
21use std::path::PathBuf;
22use std::time::SystemTime;
23
24#[derive(Debug, Parser)]
25#[command(about = "A tool to \"decompile\" scripts to Python for easier inspection")]
26pub struct Options {
27    #[arg(long = "input", short = 'i', help = "The path to the input file")]
28    input: PathBuf,
29
30    #[arg(long = "id", help = "The id of the item to convert")]
31    id: Option<u32>,
32
33    #[arg(long = "event-page", help = "The event page to convert")]
34    event_page: Option<u16>,
35
36    #[arg(long = "config", short = 'c', help = "The path to the config to use")]
37    config: Option<PathBuf>,
38
39    #[arg(long = "dry-run", help = "Avoid writing the output files")]
40    dry_run: bool,
41
42    #[arg(long = "output", short = 'o', help = "The path to the output file")]
43    output: Option<PathBuf>,
44
45    #[arg(
46        long = "overwrite",
47        help = "Whether to overwrite the output, if it exists"
48    )]
49    overwrite: bool,
50
51    #[arg(
52        long = "use-mtimes",
53        help = "Check mtimes to skip assets that don't need to be converted again"
54    )]
55    use_mtimes: bool,
56}
57
58pub fn exec(options: Options) -> anyhow::Result<()> {
59    ensure!(
60        options.overwrite || !options.use_mtimes,
61        "the --use-mtimes flag must be used with the --overwrite flag"
62    );
63
64    let largest_mtime = if options.use_mtimes {
65        let current_exe = std::env::current_exe().context("failed to get current exe")?;
66        let current_exe_mtime = std::fs::metadata(current_exe)
67            .context("failed to get metadata for current exe")?
68            .modified()?;
69
70        let mut largest_mtime = current_exe_mtime;
71
72        if let Some(config) = options.config.as_ref() {
73            let config_mtime = std::fs::metadata(config)
74                .context("failed to get file metadata for config")?
75                .modified()?;
76            largest_mtime = std::cmp::max(largest_mtime, config_mtime);
77        }
78
79        Some(largest_mtime)
80    } else {
81        None
82    };
83
84    let config = match options.config {
85        Some(config) => Config::from_path(&config)
86            .with_context(|| format!("failed to load config from \"{}\"", config.display()))?,
87        None => Config::default(),
88    };
89
90    let input_file_kind = FileKind::new(&options.input, true)
91        .map(|kind| kind.context("unknown file type"))
92        .and_then(std::convert::identity)
93        .with_context(|| {
94            format!(
95                "failed to determine file kind for \"{}\"",
96                options.input.display()
97            )
98        })?;
99
100    if input_file_kind.is_dir() {
101        let output = options.output.as_deref().unwrap_or("out".as_ref());
102        ensure!(
103            options.id.is_none(),
104            "the --id flag is unsupported for directories"
105        );
106        ensure!(
107            options.event_page.is_none(),
108            "the --event-page flag is unsupported for directories"
109        );
110
111        dump_dir(
112            &options.input,
113            options.dry_run,
114            options.overwrite,
115            &config,
116            largest_mtime,
117            output,
118        )?;
119    } else {
120        let id = options
121            .id
122            .context("the item id must be specified with the --id option")?;
123        let output = options.output.as_deref().unwrap_or("out.py".as_ref());
124
125        dump_file(
126            input_file_kind,
127            largest_mtime,
128            DumpFileOptions {
129                input: &options.input,
130
131                config: &config,
132                id,
133                event_page: options.event_page,
134
135                output,
136                dry_run: options.dry_run,
137                overwrite: options.overwrite,
138            },
139        )?;
140    }
141
142    Ok(())
143}
144
145fn dump_dir(
146    input: &Path,
147    dry_run: bool,
148    overwrite: bool,
149    config: &Config,
150    largest_mtime: Option<SystemTime>,
151    output: &Path,
152) -> anyhow::Result<()> {
153    ensure!(
154        overwrite || !output.try_exists()?,
155        "output path \"{}\" already exists. Use the --overwrite flag to overwrite",
156        output.display()
157    );
158    if !dry_run {
159        try_create_dir(output).context("failed to create output dir")?;
160    }
161
162    for dir_entry in std::fs::read_dir(input)? {
163        let dir_entry = dir_entry?;
164        let file_type = dir_entry.file_type()?;
165
166        if file_type.is_dir() {
167            continue;
168        }
169
170        let input = dir_entry.path();
171        if input.extension() != Some("json".as_ref()) {
172            continue;
173        }
174        let input_file_kind = FileKind::new(&input, false).with_context(|| {
175            format!("failed to determine file kind for \"{}\"", input.display())
176        })?;
177        let input_str = std::fs::read_to_string(&input)
178            .with_context(|| format!("failed to read \"{}\"", input.display()))?;
179
180        let mut output = output.to_path_buf();
181        let input_file_kind = match input_file_kind {
182            Some(input_file_kind) => input_file_kind,
183            None => continue,
184        };
185        match input_file_kind {
186            FileKind::Map => {
187                output.push("maps");
188
189                let file_stem = input
190                    .file_stem()
191                    .context("missing file stem")?
192                    .to_str()
193                    .context("map name is not valid unicode")?;
194                let map_id = extract_map_id(file_stem)?.context("missing map id")?;
195
196                output.push(format!("{map_id:03}"));
197
198                let map: rpgmv_types::Map = serde_json::from_str(&input_str)
199                    .with_context(|| format!("failed to parse \"{}\"", input.display()))?;
200
201                for (event_id, event) in map.events.iter().enumerate() {
202                    let event = match event {
203                        Some(event) => event,
204                        None => continue,
205                    };
206                    let event_id_u32 = u32::try_from(event_id)?;
207
208                    for (page_index, page) in event.pages.iter().enumerate() {
209                        if page.list.iter().all(|command| command.code == 0) {
210                            continue;
211                        }
212                        let page_index_u16 = u16::try_from(page_index)?;
213
214                        let file_name =
215                            format!("event_{event_id_u32:02}_page_{page_index_u16:02}.py");
216                        let output = output.join(file_name);
217
218                        if !dry_run && let Some(parent) = output.parent() {
219                            std::fs::create_dir_all(parent).with_context(|| {
220                                format!("failed to create dir at\"{}\"", parent.display())
221                            })?;
222                        }
223
224                        dump_file(
225                            input_file_kind,
226                            largest_mtime,
227                            DumpFileOptions {
228                                input: &input,
229
230                                config,
231                                id: event_id_u32,
232                                event_page: Some(page_index_u16),
233
234                                output: &output,
235                                dry_run,
236                                overwrite,
237                            },
238                        )?;
239                    }
240                }
241            }
242            FileKind::CommonEvents => {
243                output.push("common-events");
244
245                let common_events: Vec<Option<rpgmv_types::CommonEvent>> =
246                    serde_json::from_str(&input_str)
247                        .with_context(|| format!("failed to parse \"{}\"", input.display()))?;
248
249                for (common_event_id, common_event) in common_events.iter().enumerate() {
250                    let common_event = match common_event {
251                        Some(common_event) => common_event,
252                        None => {
253                            continue;
254                        }
255                    };
256                    let common_event_id_u32 = u32::try_from(common_event_id)?;
257
258                    let event_name = config
259                        .common_events
260                        .get(&common_event_id_u32)
261                        .unwrap_or(&common_event.name);
262                    let sanitized_event_name = sanitize_file_name(event_name);
263                    let output_file_name =
264                        format!("{common_event_id_u32:03}_{sanitized_event_name}.py");
265                    let output = output.join(output_file_name);
266
267                    if !dry_run && let Some(parent) = output.parent() {
268                        std::fs::create_dir_all(parent).with_context(|| {
269                            format!("failed to create dir at\"{}\"", parent.display())
270                        })?;
271                    }
272
273                    dump_file(
274                        input_file_kind,
275                        largest_mtime,
276                        DumpFileOptions {
277                            input: &input,
278
279                            config,
280                            id: common_event_id_u32,
281                            event_page: None,
282
283                            output: &output,
284                            dry_run,
285                            overwrite,
286                        },
287                    )?;
288                }
289            }
290            FileKind::Troops => {
291                output.push("troops");
292
293                let troops: Vec<Option<rpgmv_types::Troop>> = serde_json::from_str(&input_str)
294                    .with_context(|| format!("failed to parse \"{}\"", input.display()))?;
295
296                for (troop_id, troop) in troops.iter().enumerate() {
297                    let troop = match troop {
298                        Some(troop) => troop,
299                        None => {
300                            continue;
301                        }
302                    };
303                    let troop_id_u32 = u32::try_from(troop_id)?;
304                    let troop_name = troop.name.replace('*', "*");
305
306                    for (page_index, page) in troop.pages.iter().enumerate() {
307                        let page_index_u16 = u16::try_from(page_index)?;
308
309                        if page.list.iter().all(|command| command.code == 0) {
310                            continue;
311                        }
312
313                        let output_file_name =
314                            format!("{troop_id_u32:02}_page_{page_index:02}_{troop_name}.py");
315                        let output = output.join(output_file_name);
316
317                        if !dry_run && let Some(parent) = output.parent() {
318                            std::fs::create_dir_all(parent).with_context(|| {
319                                format!("failed to create dir at\"{}\"", parent.display())
320                            })?;
321                        }
322
323                        dump_file(
324                            input_file_kind,
325                            largest_mtime,
326                            DumpFileOptions {
327                                input: &input,
328
329                                config,
330                                id: troop_id_u32,
331                                event_page: Some(page_index_u16),
332
333                                output: &output,
334                                dry_run,
335                                overwrite,
336                            },
337                        )?;
338                    }
339                }
340            }
341            FileKind::Dir => {
342                bail!("input is a dir");
343            }
344        }
345    }
346
347    Ok(())
348}
349
350#[derive(Debug)]
351struct DumpFileOptions<'a> {
352    input: &'a Path,
353
354    config: &'a Config,
355    id: u32,
356    event_page: Option<u16>,
357
358    output: &'a Path,
359    dry_run: bool,
360    overwrite: bool,
361}
362
363fn dump_file(
364    input_file_kind: FileKind,
365    last_mtime: Option<SystemTime>,
366    options: DumpFileOptions<'_>,
367) -> anyhow::Result<()> {
368    let input_str = std::fs::read_to_string(options.input)
369        .with_context(|| format!("failed to read \"{}\"", options.input.display()))?;
370    let input_mtime = std::fs::metadata(options.input)
371        .with_context(|| format!("failed to get metadata for \"{}\"", options.input.display()))?
372        .modified()?;
373    let output_mtime = match std::fs::metadata(options.output) {
374        Ok(metadata) => Some(metadata.modified()?),
375        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
376        Err(error) => {
377            return Err(error).with_context(|| {
378                format!("failed to get metadata for \"{}\"", options.input.display())
379            })?;
380        }
381    };
382    if options.overwrite
383        && let (Some(last_mtime), Some(output_mtime)) = (last_mtime, output_mtime)
384    {
385        let last_mtime = std::cmp::max(last_mtime, input_mtime);
386
387        if last_mtime < output_mtime {
388            return Ok(());
389        }
390    }
391
392    let event_commands = match input_file_kind {
393        FileKind::Map => {
394            let mut map: rpgmv_types::Map = serde_json::from_str(&input_str)
395                .with_context(|| format!("failed to parse \"{}\"", options.input.display()))?;
396
397            let mut event = usize::try_from(options.id)
398                .ok()
399                .and_then(|id| {
400                    if id >= map.events.len() {
401                        return None;
402                    }
403
404                    map.events.swap_remove(id)
405                })
406                .with_context(|| format!("no event with id {}", options.id))?;
407            ensure!(event.id == options.id);
408
409            let event_page_index = match options.event_page {
410                Some(event_page) => event_page,
411                None if event.pages.len() == 1 => 0,
412                None => {
413                    bail!(
414                        "found multiple event pages. specify which one with the --event-page option"
415                    )
416                }
417            };
418            let event_page_index = usize::from(event_page_index);
419            ensure!(
420                event_page_index < event.pages.len(),
421                "no event page with index {event_page_index}"
422            );
423            let event_page = event.pages.swap_remove(event_page_index);
424
425            event_page.list
426        }
427        FileKind::CommonEvents => {
428            let mut common_events: Vec<Option<rpgmv_types::CommonEvent>> =
429                serde_json::from_str(&input_str)
430                    .with_context(|| format!("failed to parse \"{}\"", options.input.display()))?;
431
432            let event = usize::try_from(options.id)
433                .ok()
434                .and_then(|event_id| {
435                    if event_id >= common_events.len() {
436                        return None;
437                    }
438
439                    common_events.swap_remove(event_id)
440                })
441                .with_context(|| format!("no event with id {}", options.id))?;
442            ensure!(event.id == options.id);
443
444            ensure!(
445                options.event_page.is_none(),
446                "common events do not have pages, remove the --event-page option"
447            );
448
449            event.list
450        }
451        FileKind::Troops => {
452            let mut troops: Vec<Option<rpgmv_types::Troop>> = serde_json::from_str(&input_str)
453                .with_context(|| format!("failed to parse \"{}\"", options.input.display()))?;
454
455            let mut troop = usize::try_from(options.id)
456                .ok()
457                .and_then(|event_id| {
458                    if event_id >= troops.len() {
459                        return None;
460                    }
461
462                    troops.swap_remove(event_id)
463                })
464                .with_context(|| format!("no troop with id {}", options.id))?;
465
466            let event_page_index = match options.event_page {
467                Some(event_page) => event_page,
468                None if troop.pages.len() == 1 => 0,
469                None => {
470                    bail!(
471                        "found multiple event pages. specify which one with the --event-page option"
472                    )
473                }
474            };
475            let event_page_index = usize::from(event_page_index);
476            ensure!(
477                event_page_index < troop.pages.len(),
478                "no event page with index {event_page_index}"
479            );
480            let event_page = troop.pages.swap_remove(event_page_index);
481
482            event_page.list
483        }
484        FileKind::Dir => {
485            bail!("input is a dir");
486        }
487    };
488
489    let commands =
490        parse_event_command_list(&event_commands).context("failed to parse event command list")?;
491    let mut file_sink = FileSink::new(options.output, options.dry_run, options.overwrite)?;
492
493    commands2py(options.config, &commands, &mut file_sink)?;
494
495    file_sink.finish()?;
496
497    Ok(())
498}
499
500#[derive(Debug, Clone, Copy)]
501enum FileKind {
502    Map,
503    CommonEvents,
504    Troops,
505    Dir,
506}
507
508impl FileKind {
509    /// Try to extract a file kind from a path.
510    pub fn new(path: &Path, allow_dir: bool) -> anyhow::Result<Option<Self>> {
511        let metadata = match path.metadata() {
512            Ok(metadata) => metadata,
513            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
514                bail!("path \"{}\" does not exist", path.display());
515            }
516            Err(error) => {
517                return Err(error)
518                    .with_context(|| format!("failed to get metadata for \"{}\"", path.display()));
519            }
520        };
521        let is_file = !metadata.is_dir();
522
523        if is_file {
524            let file_name = path
525                .file_name()
526                .context("missing file name")?
527                .to_str()
528                .context("file name is not unicode")?;
529            let (file_stem, extension) = file_name
530                .rsplit_once('.')
531                .context("file name has no extension")?;
532            ensure!(extension == "json", "file must be json");
533
534            if extract_map_id(file_stem)?.is_some() {
535                return Ok(Some(Self::Map));
536            }
537
538            match file_stem {
539                "CommonEvents" => return Ok(Some(Self::CommonEvents)),
540                "Troops" => return Ok(Some(Self::Troops)),
541                _ => {}
542            }
543        } else if allow_dir {
544            return Ok(Some(Self::Dir));
545        }
546
547        Ok(None)
548    }
549
550    /// Returns `true` if this is a dir.
551    pub fn is_dir(self) -> bool {
552        matches!(self, Self::Dir)
553    }
554}
555
556/// Extracts the map number from a file name.
557///
558/// # Returns
559/// Returns `None` if this is not a map.
560fn extract_map_id(file_stem: &str) -> anyhow::Result<Option<u16>> {
561    let n = match file_stem.strip_prefix("Map") {
562        Some(n) => n,
563        None => return Ok(None),
564    };
565
566    if !n.chars().all(|c| c.is_ascii_digit()) {
567        return Ok(None);
568    }
569
570    let n: u16 = n.parse().context("failed to parse map number")?;
571
572    Ok(Some(n))
573}
574
575/// Try to create a dir.
576///
577/// Returns false if the dir already exists.
578fn try_create_dir<P>(path: P) -> std::io::Result<bool>
579where
580    P: AsRef<Path>,
581{
582    match std::fs::create_dir(path) {
583        Ok(()) => Ok(true),
584        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
585        Err(error) => Err(error),
586    }
587}
588
589/// See: https://rclone.org/overview/#encoding
590fn sanitize_file_name(file_name: &str) -> String {
591    let mut ret = String::with_capacity(file_name.len());
592
593    for ch in file_name.chars() {
594        match ch {
595            ':' => {
596                ret.push(':');
597            }
598            '/' => {
599                ret.push('/');
600            }
601            _ => {
602                ret.push(ch);
603            }
604        }
605    }
606
607    ret
608}