rpgmv_tool/command/
commands2py.rs

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