Skip to main content

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