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 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 pub fn is_dir(self) -> bool {
580 matches!(self, Self::Dir)
581 }
582}
583
584fn 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
603fn 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}