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 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 pub fn is_dir(self) -> bool {
552 matches!(self, Self::Dir)
553 }
554}
555
556fn 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
575fn 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
589fn 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}