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 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 pub fn is_dir(self) -> bool {
549 matches!(self, Self::Dir)
550 }
551}
552
553fn 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
572fn 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
586fn 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}