1mod file_entry_iter;
2
3use self::file_entry_iter::FileEntry;
4use self::file_entry_iter::FileEntryIter;
5use crate::util::ArrayLikeElement;
6use crate::GameKind;
7use anyhow::bail;
8use anyhow::ensure;
9use anyhow::Context;
10use camino::Utf8Path;
11use rpgmxp_types::Actor;
12use rpgmxp_types::Animation;
13use rpgmxp_types::Armor;
14use rpgmxp_types::Class;
15use rpgmxp_types::CommonEvent;
16use rpgmxp_types::Enemy;
17use rpgmxp_types::Item;
18use rpgmxp_types::Skill;
19use rpgmxp_types::State;
20use rpgmxp_types::Tileset;
21use rpgmxp_types::Troop;
22use rpgmxp_types::Weapon;
23use ruby_marshal::FromValueContext;
24use std::collections::BTreeMap;
25use std::fs::File;
26use std::io::Write;
27use std::path::Path;
28use std::path::PathBuf;
29
30#[derive(Debug, argh::FromArgs)]
31#[argh(
32 subcommand,
33 name = "extract-assets",
34 description = "extract the assets from a game into a format that is modifiable"
35)]
36pub struct Options {
37 #[argh(
38 positional,
39 description = "the path to the game folder or rgssad archive"
40 )]
41 pub input: PathBuf,
42
43 #[argh(positional, description = "the folder to extract-assets to")]
44 pub output: PathBuf,
45
46 #[argh(
47 switch,
48 long = "overwrite",
49 description = "whether overwrite the output directory"
50 )]
51 pub overwrite: bool,
52
53 #[argh(
54 switch,
55 long = "skip-extract-scripts",
56 description = "whether scripts should not be extracted"
57 )]
58 pub skip_extract_scripts: bool,
59
60 #[argh(
61 switch,
62 long = "skip-extract-common-events",
63 description = "whether common events should not be extracted"
64 )]
65 pub skip_extract_common_events: bool,
66
67 #[argh(
68 switch,
69 long = "skip-extract-system",
70 description = "whether system data should not be extracted"
71 )]
72 pub skip_extract_system: bool,
73
74 #[argh(
75 switch,
76 long = "skip-extract-actors",
77 description = "whether actors should not be extracted"
78 )]
79 pub skip_extract_actors: bool,
80
81 #[argh(
82 switch,
83 long = "skip-extract-weapons",
84 description = "whether weapons should not be extracted"
85 )]
86 pub skip_extract_weapons: bool,
87
88 #[argh(
89 switch,
90 long = "skip-extract-armors",
91 description = "whether armor should not be extracted"
92 )]
93 pub skip_extract_armors: bool,
94
95 #[argh(
96 switch,
97 long = "skip-extract-skills",
98 description = "whether skills should not be extracted"
99 )]
100 pub skip_extract_skills: bool,
101
102 #[argh(
103 switch,
104 long = "skip-extract-states",
105 description = "whether states should not be extracted"
106 )]
107 pub skip_extract_states: bool,
108
109 #[argh(
110 switch,
111 long = "skip-extract-items",
112 description = "whether items should not be extracted"
113 )]
114 pub skip_extract_items: bool,
115
116 #[argh(
117 switch,
118 long = "skip-extract-enemies",
119 description = "whether enemies should not be extracted"
120 )]
121 pub skip_extract_enemies: bool,
122
123 #[argh(
124 switch,
125 long = "skip-extract-classes",
126 description = "whether classes should not be extracted"
127 )]
128 pub skip_extract_classes: bool,
129
130 #[argh(
131 switch,
132 long = "skip-extract-troops",
133 description = "whether troops should not be extracted"
134 )]
135 pub skip_extract_troops: bool,
136
137 #[argh(
138 switch,
139 long = "skip-extract-tilesets",
140 description = "whether tilesets should not be extracted"
141 )]
142 pub skip_extract_tilesets: bool,
143
144 #[argh(
145 switch,
146 long = "skip-extract-map-infos",
147 description = "whether map infos should not be extracted"
148 )]
149 pub skip_extract_map_infos: bool,
150
151 #[argh(
152 switch,
153 long = "skip-extract-animations",
154 description = "whether animations should not be extracted"
155 )]
156 pub skip_extract_animations: bool,
157
158 #[argh(
159 switch,
160 long = "skip-extract-maps",
161 description = "whether maps should not be extracted"
162 )]
163 pub skip_extract_maps: bool,
164}
165
166pub fn exec(mut options: Options) -> anyhow::Result<()> {
167 options.input = options
168 .input
169 .canonicalize()
170 .context("failed to canonicalize input path")?;
171
172 if options.output.try_exists()? {
173 if options.overwrite {
174 std::fs::remove_dir_all(&options.output)?;
175 } else {
176 bail!("output path exists");
177 }
178 }
179
180 std::fs::create_dir_all(&options.output)?;
183
184 options.output = options
185 .output
186 .canonicalize()
187 .context("failed to canonicalize output path")?;
188
189 let mut file_entry_iter = FileEntryIter::new(&options.input)?;
190 let game_kind = file_entry_iter.game_kind();
191
192 while let Some(mut entry) = file_entry_iter.next_file_entry()? {
193 let raw_relative_path = entry.relative_path().to_path_buf();
194 let relative_path_components = parse_relative_path(&raw_relative_path)?;
195 let relative_path_display = relative_path_components.join("/");
196 let output_path = {
197 let mut output_path = options.output.clone();
198 output_path.extend(relative_path_components.clone());
199 output_path
200 };
201
202 println!("extracting \"{relative_path_display}\"");
203
204 if let Some(parent) = output_path.parent() {
205 std::fs::create_dir_all(parent)
206 .with_context(|| format!("failed to create dir at \"{}\"", parent.display()))?;
207 }
208
209 match game_kind {
210 GameKind::Xp => {
211 extract_xp(&options, &mut entry, relative_path_components, output_path)?
212 }
213 GameKind::Vx => {
214 extract_vx(&options, &mut entry, relative_path_components, output_path)?
215 }
216 }
217 }
218
219 Ok(())
220}
221
222fn extract_xp(
223 options: &Options,
224 entry: &mut FileEntry<'_>,
225 relative_path_components: Vec<&str>,
226 output_path: PathBuf,
227) -> anyhow::Result<()> {
228 match relative_path_components.as_slice() {
229 ["Data", "Scripts.rxdata"] if !options.skip_extract_scripts => {
230 extract_scripts(entry, output_path)?;
231 }
232 ["Data", "CommonEvents.rxdata"] if !options.skip_extract_common_events => {
233 extract_arraylike::<CommonEvent>(entry, output_path)?;
234 }
235 ["Data", "Actors.rxdata"] if !options.skip_extract_actors => {
236 extract_arraylike::<Actor>(entry, output_path)?;
237 }
238 ["Data", "Weapons.rxdata"] if !options.skip_extract_weapons => {
239 extract_arraylike::<Weapon>(entry, output_path)?;
240 }
241 ["Data", "Armors.rxdata"] if !options.skip_extract_armors => {
242 extract_arraylike::<Armor>(entry, output_path)?;
243 }
244 ["Data", "Skills.rxdata"] if !options.skip_extract_skills => {
245 extract_arraylike::<Skill>(entry, output_path)?;
246 }
247 ["Data", "States.rxdata"] if !options.skip_extract_states => {
248 extract_arraylike::<State>(entry, output_path)?;
249 }
250 ["Data", "Items.rxdata"] if !options.skip_extract_items => {
251 extract_arraylike::<Item>(entry, output_path)?;
252 }
253 ["Data", "Enemies.rxdata"] if !options.skip_extract_enemies => {
254 extract_arraylike::<Enemy>(entry, output_path)?;
255 }
256 ["Data", "Classes.rxdata"] if !options.skip_extract_classes => {
257 extract_arraylike::<Class>(entry, output_path)?;
258 }
259 ["Data", "Troops.rxdata"] if !options.skip_extract_troops => {
260 extract_arraylike::<Troop>(entry, output_path)?;
261 }
262 ["Data", "Tilesets.rxdata"] if !options.skip_extract_tilesets => {
263 extract_arraylike::<Tileset>(entry, output_path)?;
264 }
265 ["Data", "MapInfos.rxdata"] if !options.skip_extract_map_infos => {
266 extract_map_infos(entry, output_path)?;
267 }
268 ["Data", "System.rxdata"] if !options.skip_extract_system => {
269 extract_ruby_data::<rpgmxp_types::System>(entry, output_path)?;
270 }
271 ["Data", "Animations.rxdata"] if !options.skip_extract_animations => {
272 extract_arraylike::<Animation>(entry, output_path)?;
273 }
274 ["Data", file]
275 if !options.skip_extract_maps && crate::util::is_map_file_name(file, "rxdata") =>
276 {
277 extract_ruby_data::<rpgmxp_types::Map>(entry, output_path)?;
278 }
279 _ => {
280 let temp_path = nd_util::with_push_extension(&output_path, "temp");
281 let mut output_file = File::create(&temp_path)
284 .with_context(|| format!("failed to open file at \"{}\"", output_path.display()))?;
285
286 std::io::copy(entry, &mut output_file)?;
287 std::fs::rename(&temp_path, &output_path)?;
288 }
289 }
290
291 Ok(())
292}
293
294fn extract_vx(
295 options: &Options,
296 entry: &mut FileEntry<'_>,
297 relative_path_components: Vec<&str>,
298 output_path: PathBuf,
299) -> anyhow::Result<()> {
300 match relative_path_components.as_slice() {
301 ["Data", "Scripts.rvdata"] if !options.skip_extract_scripts => {
302 extract_scripts(entry, output_path)?;
303 }
304 ["Data", "MapInfos.rvdata"] if !options.skip_extract_map_infos => {
305 extract_map_infos(entry, output_path)?;
306 }
307 ["Data", "System.rvdata"] if !options.skip_extract_system => {
308 extract_ruby_data::<rpgmvx_types::System>(entry, output_path)?;
309 }
310 ["Data", file]
311 if !options.skip_extract_maps && crate::util::is_map_file_name(file, "rvdata") =>
312 {
313 extract_ruby_data::<rpgmvx_types::Map>(entry, output_path)?;
314 }
315 _ => {
316 let temp_path = nd_util::with_push_extension(&output_path, "temp");
317 let mut output_file = File::create(&temp_path)
320 .with_context(|| format!("failed to open file at \"{}\"", output_path.display()))?;
321
322 std::io::copy(entry, &mut output_file)?;
323 std::fs::rename(&temp_path, &output_path)?;
324 }
325 }
326
327 Ok(())
328}
329
330fn parse_relative_path(path: &Utf8Path) -> anyhow::Result<Vec<&str>> {
331 let mut components = Vec::with_capacity(4);
332
333 for component in path.as_str().split(['/', '\\']) {
349 ensure!(!component.is_empty());
350 ensure!(component != "..");
351 ensure!(!component.contains(':'));
352
353 if component == "." {
354 continue;
355 }
356
357 components.push(component);
358 }
359
360 Ok(components)
361}
362
363fn extract_scripts<P>(file: impl std::io::Read, dir_path: P) -> anyhow::Result<()>
364where
365 P: AsRef<Path>,
366{
367 let dir_path = dir_path.as_ref();
368 let temp_dir_path = nd_util::with_push_extension(dir_path, "temp");
369
370 std::fs::create_dir_all(&temp_dir_path)?;
373
374 let arena = ruby_marshal::load(file)?;
375 let ctx = FromValueContext::new(&arena);
376 let script_list: rpgm_common_types::ScriptList = ctx.from_value(arena.root())?;
377
378 for (script_index, script) in script_list.scripts.iter().enumerate() {
379 println!(" extracting script \"{}\"", script.name);
380
381 let escaped_script_name = crate::util::percent_escape_file_name(&script.name);
382
383 let out_path = temp_dir_path.join(format!("{script_index:03}-{escaped_script_name}.rb"));
384 let temp_path = nd_util::with_push_extension(&out_path, "temp");
385
386 std::fs::write(&temp_path, &script.data)?;
389 std::fs::rename(temp_path, out_path)?;
390 }
391
392 std::fs::rename(temp_dir_path, dir_path)?;
393
394 Ok(())
395}
396
397fn extract_arraylike<T>(file: impl std::io::Read, dir_path: impl AsRef<Path>) -> anyhow::Result<()>
398where
399 T: for<'a> ArrayLikeElement<'a>,
400{
401 let dir_path = dir_path.as_ref();
402 let type_display_name = T::type_display_name();
403
404 std::fs::create_dir_all(dir_path)?;
405
406 let arena = ruby_marshal::load(file)?;
407 let ctx = FromValueContext::new(&arena);
408 let array: Vec<Option<T>> = ctx.from_value(arena.root())?;
409
410 for (index, value) in array.iter().enumerate() {
411 if index == 0 {
412 ensure!(value.is_none(), "{type_display_name} 0 should be nil");
413 continue;
414 }
415
416 let value = value
417 .as_ref()
418 .with_context(|| format!("{type_display_name} is nil"))?;
419
420 println!(" extracting {} \"{}\"", type_display_name, value.name());
421
422 let name = value.name();
423 let file_name = format!("{index:03}-{name}.json");
424 let file_name = crate::util::percent_escape_file_name(file_name.as_str());
425 let out_path = dir_path.join(file_name);
426 let temp_path = nd_util::with_push_extension(&out_path, "temp");
427
428 let mut output_file = File::create_new(&temp_path)?;
431 serde_json::to_writer_pretty(&mut output_file, value)?;
432 output_file.flush()?;
433 output_file.sync_all()?;
434 drop(output_file);
435
436 std::fs::rename(temp_path, out_path)?;
437 }
438
439 Ok(())
440}
441
442fn extract_map_infos<P>(file: impl std::io::Read, dir_path: P) -> anyhow::Result<()>
443where
444 P: AsRef<Path>,
445{
446 let dir_path = dir_path.as_ref();
447
448 std::fs::create_dir_all(dir_path)?;
449
450 let arena = ruby_marshal::load(file)?;
451 let ctx = FromValueContext::new(&arena);
452 let map: BTreeMap<i32, rpgm_common_types::MapInfo> = ctx.from_value(arena.root())?;
453
454 for (index, value) in map.iter() {
455 let name = value.name.as_str();
456
457 println!(" extracting map info \"{name}\"");
458
459 let out_path = dir_path.join(format!("{index:03}-{name}.json"));
460 let temp_path = nd_util::with_push_extension(&out_path, "temp");
461
462 let mut output_file = File::create_new(&temp_path)?;
465 serde_json::to_writer_pretty(&mut output_file, value)?;
466 output_file.flush()?;
467 output_file.sync_all()?;
468 drop(output_file);
469
470 std::fs::rename(temp_path, out_path)?;
471 }
472
473 Ok(())
474}
475
476fn extract_ruby_data<T>(file: impl std::io::Read, path: impl AsRef<Path>) -> anyhow::Result<()>
477where
478 T: serde::Serialize + for<'a> ruby_marshal::FromValue<'a>,
479{
480 let path = path.as_ref();
481 let path = path.with_extension("json");
482
483 let arena = ruby_marshal::load(file)?;
484 let ctx = FromValueContext::new(&arena);
485 let data: T = ctx.from_value(arena.root())?;
486
487 let temp_path = nd_util::with_push_extension(&path, "temp");
490 let mut file = File::create_new(&temp_path)?;
491 serde_json::to_writer_pretty(&mut file, &data)?;
492 file.flush()?;
493 file.sync_all()?;
494 std::fs::rename(temp_path, path)?;
495
496 Ok(())
497}