1use crate::capture::{CaptureFormat, CaptureOptions, CaptureOutcome, capture_table};
2use crate::config::{ResolvedConfig, SetupConfigResult};
3use crate::indexer::{DEFAULT_INDEX_FILE_NAME, IndexError, Progress};
4use crate::patcher::patch_vbs_file;
5use crate::{
6 RemoveOnDrop, config, frontend, indexer, os_independent_file_name, path_exists, strip_cr_lf,
7};
8use base64::Engine;
9use clap::builder::Str;
10use clap::{Arg, ArgAction, ArgMatches, Command, arg};
11use colored::Colorize;
12use console::Emoji;
13use directb2s::read;
14use git_version::git_version;
15use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
16use log::{LevelFilter, info};
17use pinmame_nvram::dips::get_all_dip_switches;
18use std::error::Error;
19use std::ffi::OsStr;
20use std::fmt::Display;
21use std::fs::{File, OpenOptions};
22use std::io;
23use std::io::{BufReader, BufWriter, Read, Write};
24use std::path::{Path, PathBuf};
25use std::process::{ExitCode, exit};
26use std::time::{Duration, SystemTime};
27use vpin::filesystem::RealFileSystem;
28use vpin::vpx;
29use vpin::vpx::expanded::ExpandOptions;
30use vpin::vpx::export::gltf_export::{GltfExportOptions, GltfFormat, export_gltf};
31use vpin::vpx::export::obj_export::{ExportUnits, ObjExportOptions, export_obj};
32use vpin::vpx::jsonmodel::{game_data_to_json, info_to_json};
33use vpin::vpx::{ExtractResult, VerifyResult, expanded, extractvbs, importvbs, verify};
34
35const GIT_VERSION: &str = git_version!(
37 args = ["--tags", "--always", "--dirty=-modified"],
38 prefix = "git:",
39 cargo_prefix = "cargo:",
40 fallback = "unknown"
41);
42
43const OK: Emoji = Emoji("✅", "[launch]");
44const NOK: Emoji = Emoji("❌", "[crash]");
45const WARN: Emoji = Emoji("⚠️", "[warn]");
46
47const CMD_FRONTEND: &str = "frontend";
48const CMD_DIFF: &str = "diff";
49const CMD_EXTRACT: &str = "extract";
50const CMD_ASSEMBLE: &str = "assemble";
51const CMD_EXTRACT_VBS: &str = "extractvbs";
52const CMD_IMPORT_VBS: &str = "importvbs";
53const CMD_PATCH: &str = "patch";
54const CMD_VERIFY: &str = "verify";
55const CMD_NEW: &str = "new";
56const CMD_LOCK: &str = "lock";
57const CMD_UNLOCK: &str = "unlock";
58const CMD_LOCK_STATUS: &str = "lock-status";
59
60enum LockAction {
61 Lock,
62 Unlock,
63 Status,
64}
65
66const CMD_LS: &str = "ls";
67
68const CMD_CONFIG: &str = "config";
69const CMD_CONFIG_SETUP: &str = "setup";
70const CMD_CONFIG_PATH: &str = "path";
71const CMD_CONFIG_SHOW: &str = "show";
72const CMD_CONFIG_CLEAR: &str = "clear";
73const CMD_CONFIG_EDIT: &str = "edit";
74
75const CMD_SCRIPT: &str = "script";
76const CMD_SCRIPT_SHOW: &str = "show";
77const CMD_SCRIPT_EXTRACT: &str = "extract";
78const CMD_SCRIPT_IMPORT: &str = "import";
79const CMD_SCRIPT_PATCH: &str = "patch";
80const CMD_SCRIPT_EDIT: &str = "edit";
81const CMD_SCRIPT_DIFF: &str = "diff";
82
83const CMD_INFO: &str = "info";
84const CMD_INFO_SHOW: &str = "show";
85const CMD_INFO_EXTRACT: &str = "extract";
86const CMD_INFO_IMPORT: &str = "import";
87const CMD_INFO_EDIT: &str = "edit";
88const CMD_INFO_DIFF: &str = "diff";
89
90const CMD_IMAGES: &str = "images";
91const CMD_IMAGES_WEBP: &str = "webp";
92const CMD_IMAGES_LIST: &str = "list";
93
94const CMD_SOUNDS: &str = "sounds";
95const CMD_SOUNDS_LIST: &str = "list";
96
97const CMD_COLLECTIONS: &str = "collections";
98const CMD_COLLECTIONS_LIST: &str = "list";
99
100const CMD_MATERIALS: &str = "materials";
101const CMD_MATERIALS_LIST: &str = "list";
102
103const CMD_GAMEITEMS: &str = "gameitems";
104const CMD_GAMEITEMS_LIST: &str = "list";
105
106const CMD_GAMEDATA: &str = "gamedata";
107const CMD_GAMEDATA_SHOW: &str = "show";
108
109const CMD_DIPSWITCHES: &str = "dipswitches";
110const CMD_DIPSWITCHES_SHOW: &str = "show";
111
112const CMD_NVRAM: &str = "nvram";
113const CMD_NVRAM_SHOW: &str = "show";
114
115const CMD_SCORES: &str = "scores";
116const CMD_SCORES_SHOW: &str = "show";
117
118const CMD_ROMNAME: &str = "romname";
119
120const CMD_EXPORT: &str = "export";
121const CMD_EXPORT_OBJ: &str = "obj";
122const CMD_EXPORT_GLTF: &str = "gltf";
123const CMD_EXPORT_VPXZ: &str = "vpxz";
124
125const CMD_INDEX: &str = "index";
126
127const CMD_CAPTURE: &str = "capture";
128
129const ARG_VERBOSE: &str = "VERBOSE";
130const ARG_MAX_DEPTH: &str = "MAX_DEPTH";
131const ARG_FORCE: &str = "FORCE";
132const ARG_FORMAT: &str = "FORMAT";
133const ARG_MAX_WIDTH: &str = "MAX_WIDTH_PX";
134const ARG_TIMEOUT: &str = "TIMEOUT_SECS";
135
136pub(crate) struct ProgressBarProgress {
137 pb: ProgressBar,
138}
139
140impl ProgressBarProgress {
141 pub(crate) fn new(pb: ProgressBar) -> Self {
142 Self { pb }
143 }
144}
145
146impl Progress for ProgressBarProgress {
147 fn set_length(&self, len: u64) {
148 if len > 0 {
149 self.pb.set_draw_target(ProgressDrawTarget::stdout());
150 } else {
151 self.pb.set_draw_target(ProgressDrawTarget::hidden());
152 }
153 self.pb.set_length(len)
154 }
155 fn set_position(&self, pos: u64) {
156 self.pb.set_position(pos)
157 }
158 fn finish_and_clear(&self) {
159 self.pb.finish_and_clear()
160 }
161}
162
163pub fn run() -> io::Result<ExitCode> {
164 let command = build_command();
165 let matches = command.get_matches_from(wild::args());
166
167 let verbose = matches.get_flag(ARG_VERBOSE);
168 init_logging(verbose);
169 handle_command(matches)
170}
171
172fn init_logging(verbose: bool) {
173 let start = SystemTime::now();
174 let mut builder = env_logger::Builder::from_default_env();
175 if std::env::var("RUST_LOG").is_err() {
177 builder.format(move |buf, record| {
178 let elapsed = start.elapsed().unwrap();
179 let warn_style = buf.default_level_style(record.level());
180 writeln!(
181 buf,
182 "[{:>8}.{:03}] {warn_style}{:<5}{warn_style:#} {}",
183 elapsed.as_secs(),
184 elapsed.subsec_millis(),
185 record.level(),
186 record.args()
187 )
188 });
189 if verbose {
190 builder
191 .filter_level(LevelFilter::Warn)
192 .filter_module("vpin", LevelFilter::Info)
193 .filter_module("vpxtool", LevelFilter::Info);
194 } else {
195 builder.filter_level(LevelFilter::Warn);
196 }
197 };
198 builder.init();
199}
200
201fn handle_command(matches: ArgMatches) -> io::Result<ExitCode> {
202 match matches.subcommand() {
203 Some((CMD_INFO, sub_matches)) => match sub_matches.subcommand() {
204 Some((CMD_INFO_SHOW, sub_matches)) => {
205 let path = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
206 let path = path.unwrap_or("");
207 let expanded_path = path_exists(path)?;
208 crate::println!("showing info for {}", expanded_path.display())?;
209 let info = info_gather(&expanded_path)?;
210 crate::println!("{}", info)?;
211 Ok(ExitCode::SUCCESS)
212 }
213 Some((CMD_INFO_EXTRACT, sub_matches)) => {
214 let path = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
215 let path = path.unwrap_or("");
216 let expanded_path = path_exists(path)?;
217 crate::println!("extracting info for {}", expanded_path.display())?;
218 info_extract(&expanded_path)
219 }
220 Some((CMD_INFO_IMPORT, sub_matches)) => {
221 let path = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
222 let path = path.unwrap_or("");
223 let expanded_path = path_exists(path)?;
224 crate::println!("importing info for {}", expanded_path.display())?;
225 info_import(&expanded_path)
226 }
227 Some((CMD_INFO_EDIT, sub_matches)) => {
228 let path = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
229 let path = path.unwrap_or("");
230 let expanded_path = path_exists(path)?;
231 let loaded_config = config::load_config()?;
232 let config = loaded_config.as_ref().map(|c| &c.1);
233 crate::println!("editing info for {}", expanded_path.display())?;
234 info_edit(&expanded_path, config)?;
235 Ok(ExitCode::SUCCESS)
236 }
237 Some((CMD_INFO_DIFF, sub_matches)) => {
238 let path = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
239 let path = path.unwrap_or("");
240 let expanded_path = path_exists(path)?;
241 let loaded_config = config::load_config()?;
242 let config = loaded_config.as_ref().map(|c| &c.1);
243 crate::println!("diffing info for {}", expanded_path.display())?;
244 let diff = info_diff(&expanded_path, config)?;
245 crate::println!("{}", diff)?;
246 Ok(ExitCode::SUCCESS)
247 }
248 _ => unreachable!(),
249 },
250 Some((CMD_DIFF, sub_matches)) => {
251 let path = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
253 let path = path.unwrap_or("");
254 let expanded_path = path_exists(path)?;
255 let loaded_config = config::load_config()?;
256 let config = loaded_config.as_ref().map(|c| &c.1);
257 match script_diff(&expanded_path, config) {
258 Ok(output) => {
259 crate::println!("{}", output)?;
260 Ok(ExitCode::SUCCESS)
261 }
262 Err(e) => {
263 let warning = format!("Error running diff: {e}").red();
264 crate::println!("{}", warning)?;
265 Ok(ExitCode::FAILURE)
266 }
267 }
268 }
269 Some((CMD_FRONTEND, sub_matches)) => {
270 let (config_path, mut config) = config::load_or_setup_config()?;
271 if let Some(suggested) =
272 config::stale_vpx_config_suggestion(&config.vpx_config, &config.vpx_executable)
273 && frontend::warn_stale_vpx_config(&config_path, &config.vpx_config, &suggested)
274 {
275 if let Err(e) = config::rewrite_vpx_config(&config_path, &suggested) {
276 crate::eprintln!(
277 "{}",
278 format!("Failed to rewrite {}: {e}", config_path.display()).red()
279 )?;
280 } else {
281 crate::println!("Updated vpx_config in {}", config_path.display())?;
282 }
283 config.vpx_config = suggested;
286 }
287 let configured_pinmame_folder = config.configured_pinmame_folder();
288 let max_depth = sub_matches
289 .get_one::<usize>(ARG_MAX_DEPTH)
290 .copied()
291 .or(config.tables_scan_max_depth);
292 crate::println!("Using vpxtool config file {}", config_path.display())?;
293 crate::println!("Using vpinball config file {}", config.vpx_config.display())?;
294 crate::println!(
295 "Using global pinmame folder {}",
296 config.global_pinmame_folder().display()
297 )?;
298 crate::println!(
299 "Using configured pinmame folder {}",
300 configured_pinmame_folder
301 .as_ref()
302 .map(|f| f.display().to_string())
303 .unwrap_or_else(|| "None".to_string())
304 )?;
305 match frontend::frontend_index(
306 &config,
307 true,
308 max_depth,
309 configured_pinmame_folder.as_deref(),
310 vec![],
311 ) {
312 Ok(tables) if tables.is_empty() => {
313 let warning =
314 format!("No tables found in {}", config.tables_folder.display()).red();
315 crate::eprintln!("{}", warning)?;
316 Ok(ExitCode::FAILURE)
317 }
318 Ok(vpx_files_with_tableinfo) => {
319 frontend::frontend(
320 &config,
321 configured_pinmame_folder.as_deref(),
322 vpx_files_with_tableinfo,
323 );
324 Ok(ExitCode::SUCCESS)
325 }
326 Err(IndexError::FolderDoesNotExist(path)) => {
327 let warning = format!(
328 "Configured tables folder does not exist: {}",
329 path.display()
330 )
331 .red();
332 crate::eprintln!("{}", warning)?;
333 Ok(ExitCode::FAILURE)
334 }
335 Err(IndexError::IoError(e)) => {
336 let warning = format!("Error running frontend: {e}").red();
337 crate::eprintln!("{}", warning)?;
338 Ok(ExitCode::FAILURE)
339 }
340 }
341 }
342 Some((CMD_INDEX, sub_matches)) => handle_index(sub_matches),
343 Some((CMD_CAPTURE, sub_matches)) => handle_capture(sub_matches),
344 Some((CMD_SCRIPT, sub_matches)) => match sub_matches.subcommand() {
345 Some((CMD_SCRIPT_SHOW, sub_matches)) => {
346 let path = sub_matches
347 .get_one::<String>("VPXPATH")
348 .map(|s| s.as_str())
349 .unwrap_or_default();
350
351 let expanded_path = path_exists(path)?;
352 let mut vpx_file = vpx::open(expanded_path)?;
353 let game_data = vpx_file.read_gamedata()?;
354 let code = game_data.code.string;
355
356 crate::println!("{}", code)?;
357 Ok(ExitCode::SUCCESS)
358 }
359 Some((CMD_SCRIPT_EXTRACT, sub_matches)) => handle_extractvbs(sub_matches),
360 Some((CMD_SCRIPT_IMPORT, sub_matches)) => {
361 let path = sub_matches
362 .get_one::<String>("VPXPATH")
363 .map(|s| s.as_str())
364 .unwrap_or_default();
365
366 let vbs_path_opt = sub_matches.get_one::<String>("VBSPATH").map(PathBuf::from);
367
368 let expanded_path = path_exists(path)?;
369 match importvbs(&expanded_path, vbs_path_opt) {
370 Ok(vbs_path) => {
371 crate::println!("IMPORTED {}", vbs_path.display())?;
372 Ok(ExitCode::SUCCESS)
373 }
374 Err(e) => {
375 let warning = format!("Error importing vbs: {e}").red();
376 crate::eprintln!("{}", warning)?;
377 Ok(ExitCode::FAILURE)
378 }
379 }
380 }
381 Some((CMD_SCRIPT_EDIT, sub_matches)) => {
382 let path = sub_matches
383 .get_one::<String>("VPXPATH")
384 .map(|s| s.as_str())
385 .unwrap_or_default();
386
387 let expanded_vpx_path = path_exists(path)?;
388
389 let loaded_config = config::load_config()?;
390 let config = loaded_config.as_ref().map(|c| &c.1);
391 let vbs_path = vpx::vbs_path_for(&expanded_vpx_path);
392 if vbs_path.exists() {
393 open_or_fail(&vbs_path, config)
394 } else {
395 extractvbs(&expanded_vpx_path, None, false)?;
396 open_or_fail(&vbs_path, config)
397 }
398 }
399 Some((CMD_SCRIPT_DIFF, sub_matches)) => {
400 let path = sub_matches
401 .get_one::<String>("VPXPATH")
402 .map(|s| s.as_str())
403 .unwrap_or_default();
404
405 let expanded_path = path_exists(path)?;
406 let loaded_config = config::load_config()?;
407 let config = loaded_config.as_ref().map(|c| &c.1);
408 let diff = script_diff(&expanded_path, config)?;
409 crate::println!("{}", diff)?;
410 Ok(ExitCode::SUCCESS)
411 }
412 Some((CMD_SCRIPT_PATCH, sub_matches)) => {
413 let path = sub_matches
414 .get_one::<String>("VPXPATH")
415 .map(|s| s.as_str())
416 .unwrap_or_default();
417
418 let expanded_path = path_exists(path)?;
419 let vbs_path = match extractvbs(&expanded_path, None, false) {
420 Ok(ExtractResult::Existed(vbs_path)) => {
421 let warning =
422 format!("EXISTED {}", vbs_path.display()).truecolor(255, 125, 0);
423 crate::println!("{}", warning)?;
424 vbs_path
425 }
426 Ok(ExtractResult::Extracted(vbs_path)) => {
427 crate::println!("CREATED {}", vbs_path.display())?;
428 vbs_path
429 }
430 Err(e) => return fail_with_error("Error extracting vbs", e),
431 };
432
433 let applied = patch_vbs_file(&vbs_path)?;
434 if applied.is_empty() {
435 crate::println!("No patches applied")?;
436 } else {
437 applied
438 .iter()
439 .try_for_each(|patch| crate::println!("Applied patch: {}", patch))?;
440 }
441 Ok(ExitCode::SUCCESS)
442 }
443 _ => unreachable!(),
444 },
445 Some((CMD_LS, sub_matches)) => {
446 let path = sub_matches
447 .get_one::<String>("VPXPATH")
448 .map(|s| s.as_str())
449 .unwrap_or_default();
450
451 let expanded_path = path_exists(path)?;
452 ls(&expanded_path)?;
453 Ok(ExitCode::SUCCESS)
454 }
455 Some((CMD_EXTRACT, sub_matches)) => {
456 let force = sub_matches.get_flag("FORCE");
457 let output_dir = sub_matches
458 .get_one::<String>("OUTPUT_DIR")
459 .map(PathBuf::from);
460 let path = sub_matches
461 .get_one::<String>("VPXPATH")
462 .map(|s| s.as_str())
463 .unwrap_or_default();
464 let expanded_path = path_exists(path)?;
465 let ext = expanded_path.extension().map(|e| e.to_ascii_lowercase());
466 match ext {
467 Some(ext) if ext == "directb2s" => {
468 crate::println!("extracting from {}", expanded_path.display())?;
469 extract_directb2s(&expanded_path, output_dir.as_deref())?;
470 Ok(ExitCode::SUCCESS)
471 }
472 Some(ext) if ext == "vpx" => {
473 crate::println!("extracting from {}", expanded_path.display())?;
474 extract(expanded_path.as_ref(), force, output_dir.as_deref())
475 }
476 _ => Err(io::Error::new(
477 io::ErrorKind::InvalidInput,
478 format!("Unknown file type: {}", expanded_path.display()),
479 )),
480 }
481 }
482 Some((CMD_ASSEMBLE, sub_matches)) => {
483 let force = sub_matches.get_flag("FORCE");
484 let dir_path = sub_matches
485 .get_one::<String>("DIRPATH")
486 .map(|s| s.as_str())
487 .unwrap_or_default();
488 let vpx_path_arg = sub_matches.get_one::<String>("VPXPATH").map(|s| s.as_str());
489 let expanded_dir_path = path_exists(dir_path)?;
490 let vpx_path = match vpx_path_arg {
491 Some(path) => PathBuf::from(path),
492 None => {
493 let file_name = match expanded_dir_path.file_name() {
494 Some(name) => format!("{}.vpx", name.to_string_lossy()),
495 None => {
496 return Err(io::Error::new(
497 io::ErrorKind::InvalidInput,
498 "Invalid directory path",
499 ));
500 }
501 };
502 expanded_dir_path.with_file_name(file_name)
503 }
504 };
505 if vpx_path.exists() {
506 if force {
507 std::fs::remove_file(&vpx_path)?;
508 } else {
509 let confirmed = confirm(
510 format!("\"{}\" already exists.", vpx_path.display()),
511 "Do you want to overwrite it?".to_string(),
512 )?;
513 if !confirmed {
514 crate::println!("Aborted")?;
515 return Ok(ExitCode::FAILURE);
516 }
517 std::fs::remove_file(&vpx_path)?;
518 }
519 }
520 let result = {
521 let vpx = expanded::read(&expanded_dir_path)?;
522 vpx::write(&vpx_path, &vpx)
523 };
524 match result {
525 Ok(_) => {
526 crate::println!("Successfully assembled to {}", vpx_path.display())?;
527 Ok(ExitCode::SUCCESS)
528 }
529 Err(e) => {
530 crate::println!("Failed to assemble: {}", e)?;
531 Ok(ExitCode::FAILURE)
532 }
533 }
534 }
535 Some((CMD_EXTRACT_VBS, sub_matches)) => handle_extractvbs(sub_matches),
536 Some((CMD_IMPORT_VBS, sub_matches)) => {
537 let path: &str = sub_matches.get_one::<String>("VPXPATH").unwrap().as_str();
538 let expanded_path = path_exists(path)?;
539 match importvbs(&expanded_path, None) {
540 Ok(vbs_path) => {
541 crate::println!("IMPORTED {}", vbs_path.display())?;
542 Ok(ExitCode::SUCCESS)
543 }
544 Err(e) => {
545 let warning = format!("Error importing vbs: {e}").red();
546 crate::eprintln!("{}", warning)?;
547 Ok(ExitCode::FAILURE)
548 }
549 }
550 }
551 Some((CMD_PATCH, sub_matches)) => {
552 let vpx_path = sub_matches
553 .get_one::<String>("VPXPATH")
554 .map(|s| Path::new(OsStr::new(s)))
555 .expect("VPXPATH is required");
556 let patch_path = sub_matches
557 .get_one::<String>("PATCHPATH")
558 .map(|s| Path::new(OsStr::new(s)))
559 .expect("PATCHPATH is required");
560 let patched_vpx_path = sub_matches
561 .get_one::<String>("OUTVPXPATH")
562 .map(PathBuf::from)
563 .unwrap_or_else(|| vpx_path.with_extension("patched.vpx"));
564
565 if !vpx_path.exists() {
566 return Err(io::Error::new(
567 io::ErrorKind::NotFound,
568 format!("VPXPATH not found: {}", vpx_path.display()),
569 ));
570 }
571 if !patch_path.exists() {
572 return Err(io::Error::new(
573 io::ErrorKind::NotFound,
574 format!("PATCHPATH not found: {}", patch_path.display()),
575 ));
576 }
577 if patched_vpx_path.exists() {
578 return Err(io::Error::new(
579 io::ErrorKind::AlreadyExists,
580 format!("OUTVPXPATH already exists: {}", patched_vpx_path.display()),
581 ));
582 }
583 let vpx_file = File::open(vpx_path)?;
584 let patch_file = File::open(patch_path)?;
585 let patched_vpx_file = File::create(patched_vpx_path)?;
586
587 let mut vpx_reader = BufReader::new(vpx_file);
588 let mut patch_reader = BufReader::new(patch_file);
589 let mut patched_vpx_writer = BufWriter::new(patched_vpx_file);
590
591 jojodiff::patch(&mut vpx_reader, &mut patch_reader, &mut patched_vpx_writer)?;
592
593 patched_vpx_writer.flush()?;
594
595 Ok(ExitCode::SUCCESS)
596 }
597
598 Some((CMD_VERIFY, sub_matches)) => {
599 let paths: Vec<&str> = sub_matches
600 .get_many::<String>("VPXPATH")
601 .unwrap_or_default()
602 .map(|v| v.as_str())
603 .collect::<Vec<_>>();
604 for path in paths {
605 let expanded_path = path_exists(path)?;
606 match verify(&expanded_path) {
607 VerifyResult::Ok(vbs_path) => {
608 crate::println!("{OK} {}", vbs_path.display())?;
609 }
610 VerifyResult::Failed(vbs_path, msg) => {
611 let warning =
612 format!("{NOK} {} {}", vbs_path.display(), msg).truecolor(255, 125, 0);
613 crate::eprintln!("{}", warning)?;
614 }
615 }
616 }
617 Ok(ExitCode::SUCCESS)
618 }
619
620 Some((CMD_LOCK, sub_matches)) => run_lock(sub_matches, LockAction::Lock),
621 Some((CMD_UNLOCK, sub_matches)) => run_lock(sub_matches, LockAction::Unlock),
622 Some((CMD_LOCK_STATUS, sub_matches)) => run_lock(sub_matches, LockAction::Status),
623 Some((CMD_NEW, sub_matches)) => {
624 let path = {
625 let this = sub_matches.get_one::<String>("VPXPATH").map(|v| v.as_str());
626 match this {
627 Some(x) => x,
628 None => unreachable!("VPXPATH is required"),
629 }
630 };
631
632 crate::println!("creating new vpx file at {}", path)?;
633 new(path)?;
634 Ok(ExitCode::SUCCESS)
635 }
636 Some((CMD_CONFIG, sub_matches)) => match sub_matches.subcommand() {
637 Some((CMD_CONFIG_SETUP, _)) => match config::setup_config() {
638 Ok(SetupConfigResult::Configured(config_path)) => {
639 crate::println!("Created config file {}", config_path.display())?;
640 Ok(ExitCode::SUCCESS)
641 }
642 Ok(SetupConfigResult::Existing(config_path)) => {
643 crate::println!(
644 "Config file already exists at \"{}\"",
645 config_path.display()
646 )?;
647 Ok(ExitCode::SUCCESS)
648 }
649 Err(e) => {
650 crate::eprintln!("Failed to create config file: {}", e)?;
651 Ok(ExitCode::FAILURE)
652 }
653 },
654 Some((CMD_CONFIG_PATH, _)) => match config::config_path() {
655 Some(config_path) => {
656 crate::println!("{}", config_path.display())?;
657 Ok(ExitCode::SUCCESS)
658 }
659 None => {
660 crate::eprintln!("No config file found")?;
661 Ok(ExitCode::FAILURE)
662 }
663 },
664 Some((CMD_CONFIG_SHOW, _)) => match config::config_path() {
665 Some(config_path) => {
666 let mut file = File::open(config_path)?;
667 let mut text = String::new();
668 file.read_to_string(&mut text)?;
669 crate::println!("{}", text)?;
670 Ok(ExitCode::SUCCESS)
671 }
672 None => {
673 crate::eprintln!("No config file found")?;
674 Ok(ExitCode::FAILURE)
675 }
676 },
677 Some((CMD_CONFIG_CLEAR, _)) => match config::clear_config() {
678 Ok(Some(config_path)) => {
679 crate::println!("Cleared config file {}", config_path.display())?;
680 Ok(ExitCode::SUCCESS)
681 }
682 Ok(None) => {
683 crate::println!("No config file found")?;
684 Ok(ExitCode::SUCCESS)
685 }
686 Err(e) => fail_with_error("Failed to clear config file: {}", e),
687 },
688 Some((CMD_CONFIG_EDIT, _)) => match config::config_path() {
689 Some(config_path) => {
690 match config::load_config() {
691 Ok(loaded_config) => {
692 let config = loaded_config.as_ref().map(|c| &c.1);
693 open_editor(&config_path, config)?;
694 }
695 Err(_) => {
696 open_editor(&config_path, None)?;
698 }
699 };
700 Ok(ExitCode::SUCCESS)
701 }
702 None => fail("No config file found"),
703 },
704 _ => unreachable!(),
705 },
706 Some((CMD_IMAGES, sub_matches)) => match sub_matches.subcommand() {
707 Some((CMD_IMAGES_WEBP, sub_matches)) => {
708 let path = sub_matches
709 .get_one::<String>("VPXPATH")
710 .map(|s| s.as_str())
711 .unwrap_or_default();
712 let expanded_path = path_exists(path)?;
713 let mut vpx_file = vpx::open_rw(&expanded_path)?;
714 let images = vpx_file.images_to_webp()?;
715 if !images.is_empty() {
716 for image in images.iter() {
717 crate::println!(
718 "Updated {} from {} to {}",
719 image.name,
720 image.old_extension,
721 image.new_extension
722 )?;
723 }
724 crate::println!("Compacting vpx file")?;
725 vpx::compact(&expanded_path)?;
726 } else {
727 crate::println!("No images to update")?;
728 }
729 Ok(ExitCode::SUCCESS)
730 }
731 Some((CMD_IMAGES_LIST, sub_matches)) => handle_images_list(sub_matches),
732 _ => unreachable!(),
733 },
734 Some((CMD_SOUNDS, sub_matches)) => match sub_matches.subcommand() {
735 Some((CMD_SOUNDS_LIST, sub_matches)) => handle_sounds_list(sub_matches),
736 _ => unreachable!(),
737 },
738 Some((CMD_COLLECTIONS, sub_matches)) => match sub_matches.subcommand() {
739 Some((CMD_COLLECTIONS_LIST, sub_matches)) => handle_collections_list(sub_matches),
740 _ => unreachable!(),
741 },
742 Some((CMD_MATERIALS, sub_matches)) => match sub_matches.subcommand() {
743 Some((CMD_MATERIALS_LIST, sub_matches)) => handle_materials_list(sub_matches),
744 _ => unreachable!(),
745 },
746 Some((CMD_GAMEITEMS, sub_matches)) => match sub_matches.subcommand() {
747 Some((CMD_GAMEITEMS_LIST, sub_matches)) => handle_gameitems_list(sub_matches),
748 _ => unreachable!(),
749 },
750 Some((CMD_GAMEDATA, sub_matches)) => match sub_matches.subcommand() {
751 Some((CMD_GAMEDATA_SHOW, sub_matches)) => {
752 let path = sub_matches
753 .get_one::<String>("VPXPATH")
754 .map(|s| s.as_str())
755 .unwrap_or_default();
756 let expanded_path = path_exists(path)?;
757 let mut vpx_file = vpx::open(expanded_path)?;
758 let game_data = vpx_file.read_gamedata()?;
759 let json = game_data_to_json(&game_data);
760 let pretty = serde_json::to_string_pretty(&json)?;
761 crate::println!("{}", pretty)?;
762 Ok(ExitCode::SUCCESS)
763 }
764 _ => unreachable!(),
765 },
766 Some((CMD_DIPSWITCHES, sub_matches)) => match sub_matches.subcommand() {
767 Some((CMD_DIPSWITCHES_SHOW, sub_matches)) => {
768 let path = sub_matches
769 .get_one::<String>("NVRAMPATH")
770 .map(|s| s.as_str())
771 .unwrap_or_default();
772 let expanded_path = path_exists(path)?;
773 let summary = show_dip_switches(&expanded_path)?;
774 crate::println!("{}", summary)?;
775 Ok(ExitCode::SUCCESS)
776 }
777 _ => unreachable!(),
778 },
779 Some((CMD_ROMNAME, sub_matches)) => {
780 let path = sub_matches
781 .get_one::<String>("VPXPATH")
782 .map(|s| s.as_str())
783 .unwrap_or_default();
784 let expanded_path = path_exists(path)?;
785 if let Some(rom_name) = indexer::get_romname_from_vpx(&expanded_path)? {
786 crate::println!("{rom_name}")?;
787 }
788 Ok(ExitCode::SUCCESS)
789 }
790 Some((CMD_NVRAM, sub_matches)) => match sub_matches.subcommand() {
791 Some((CMD_NVRAM_SHOW, sub_matches)) => handle_nvram_show(sub_matches),
792 _ => unreachable!(),
793 },
794 Some((CMD_SCORES, sub_matches)) => match sub_matches.subcommand() {
795 Some((CMD_SCORES_SHOW, sub_matches)) => handle_scores_show(sub_matches),
796 _ => unreachable!(),
797 },
798 Some((CMD_EXPORT, sub_matches)) => match sub_matches.subcommand() {
799 Some((CMD_EXPORT_OBJ, sub_matches)) => handle_export_obj(sub_matches),
800 Some((CMD_EXPORT_GLTF, sub_matches)) => handle_export_gltf(sub_matches),
801 Some((CMD_EXPORT_VPXZ, sub_matches)) => handle_export_vpxz(sub_matches),
802 _ => unreachable!(),
803 },
804 _ => unreachable!(), }
806}
807
808fn handle_index(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
809 let recursive = sub_matches.get_flag("RECURSIVE");
810 let force = sub_matches.get_flag("FORCE");
811 let max_depth_cli = sub_matches.get_one::<usize>(ARG_MAX_DEPTH).copied();
812 let tables_folders_path_arg = sub_matches
813 .get_one::<String>("VPXROOTPATH")
814 .map(|s| s.as_str());
815 let index_file_arg = sub_matches
816 .get_one::<String>("INDEX_FILE")
817 .map(|s| s.as_str());
818 let config = config::load_config()?;
819
820 let tables_folder_path = match tables_folders_path_arg {
821 Some(path) => path_exists(path)?,
822 None => match &config {
823 Some((_, config)) => config.tables_folder.clone(),
824 None => {
825 crate::eprintln!("No VPXROOTPATH provided up and no vpxtool config file found")?;
826 exit(1);
827 }
828 },
829 };
830
831 let tables_index_path = match index_file_arg {
832 Some(path) => PathBuf::from(path),
833 None => tables_folder_path.join(DEFAULT_INDEX_FILE_NAME),
834 };
835
836 let global_pinmame_folder = config.as_ref().map(|(_, c)| c.global_pinmame_folder());
837 let configured_pinmame_folder = config
838 .as_ref()
839 .and_then(|(_, c)| c.configured_pinmame_folder());
840 let max_depth = max_depth_cli.or(config.as_ref().and_then(|(_, c)| c.tables_scan_max_depth));
841
842 crate::println!("Using tables folder {}", tables_folder_path.display())?;
843 if let Some(max_depth) = max_depth {
844 crate::println!("Using tables scan max depth {}", max_depth)?;
845 }
846 match &global_pinmame_folder {
847 Some(folder) => {
848 crate::println!("Using global pinmame folder {}", folder.display())?;
849 }
850 None => {
851 crate::println!("Not looking for global pinmame roms as the folder is not configured.")?
852 }
853 }
854 match &configured_pinmame_folder {
855 Some(folder) => {
856 crate::println!("Using VPinballX.ini PinMAMEPath {}", folder.display())?;
857 }
858 None => crate::println!("VPinballX.ini PinMAMEPath not used as not configured.")?,
859 }
860 crate::println!("Storing index to {}", tables_index_path.display())?;
861
862 let pb = ProgressBar::hidden();
863 pb.set_style(
864 ProgressStyle::with_template(
865 "{spinner:.green} [{bar:.cyan/blue}] {pos}/{human_len} ({eta})",
866 )
867 .unwrap(),
868 );
869 let progress = ProgressBarProgress::new(pb);
870 let index = indexer::index_folder(
871 recursive,
872 max_depth,
873 &tables_folder_path,
874 &tables_index_path,
875 global_pinmame_folder.as_deref(),
876 configured_pinmame_folder.as_deref(),
877 &progress,
878 vec![],
879 force,
880 )?;
881 progress.finish_and_clear();
882 crate::println!("Indexed {} vpx files", index.len(),)?;
883 Ok(ExitCode::SUCCESS)
884}
885
886fn handle_capture(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
887 let force = sub_matches.get_flag(ARG_FORCE);
888 let format = sub_matches
889 .get_one::<String>(ARG_FORMAT)
890 .map(|s| s.parse::<CaptureFormat>())
891 .transpose()
892 .map_err(io::Error::other)?
893 .unwrap_or_default();
894 let max_width = sub_matches.get_one::<u32>(ARG_MAX_WIDTH).copied();
895 let timeout = sub_matches
896 .get_one::<u64>(ARG_TIMEOUT)
897 .copied()
898 .filter(|&s| s > 0)
899 .map(Duration::from_secs);
900 let options = CaptureOptions {
901 format,
902 force,
903 max_width,
904 timeout,
905 ..CaptureOptions::default()
906 };
907
908 let (config_path, config) = config::load_or_setup_config()?;
909 crate::println!("Using vpxtool config file {}", config_path.display())?;
910
911 if !config.vpx_executable.is_file() {
912 crate::eprintln!(
913 "{}",
914 format!(
915 "vpinball executable not found at {}",
916 config.vpx_executable.display()
917 )
918 .red()
919 )?;
920 return Ok(ExitCode::FAILURE);
921 }
922
923 if let Some(vpx_path_arg) = sub_matches.get_one::<String>("VPXPATH") {
925 let vpx_path = path_exists(vpx_path_arg)?;
926 return match capture_table(&config, &vpx_path, &options) {
927 Ok(CaptureOutcome::Captured(_)) => Ok(ExitCode::SUCCESS),
928 Ok(CaptureOutcome::CapturedAfterHang(path)) => {
929 crate::eprintln!(
930 "{}",
931 format!(
932 "{WARN} vpinball hung and was killed; salvaged frame for {}",
933 path.display()
934 )
935 .yellow()
936 )?;
937 Ok(ExitCode::SUCCESS)
938 }
939 Ok(CaptureOutcome::Skipped(path)) => {
940 crate::println!(
941 "Skipping, {} already exists (use --force to regenerate)",
942 path.display()
943 )?;
944 Ok(ExitCode::SUCCESS)
945 }
946 Err(e) => {
947 crate::eprintln!("{}", format!("{NOK} Capture failed: {e}").red())?;
948 Ok(ExitCode::FAILURE)
949 }
950 };
951 }
952
953 let configured_pinmame_folder = config.configured_pinmame_folder();
955 let tables = match frontend::frontend_index(
956 &config,
957 true,
958 config.tables_scan_max_depth,
959 configured_pinmame_folder.as_deref(),
960 vec![],
961 ) {
962 Ok(tables) => tables,
963 Err(e) => {
964 crate::eprintln!("{}", format!("Unable to index tables: {e:?}").red())?;
965 return Ok(ExitCode::FAILURE);
966 }
967 };
968
969 crate::println!(
970 "Capturing {} screenshots for {} tables in {}",
971 format,
972 tables.len(),
973 config.tables_folder.display()
974 )?;
975
976 let mut captured = 0;
977 let mut hung = 0;
978 let mut skipped = 0;
979 let mut failed = 0;
980 for table in &tables {
981 match capture_table(&config, &table.path, &options) {
982 Ok(CaptureOutcome::Captured(_)) => {
983 captured += 1;
984 }
985 Ok(CaptureOutcome::CapturedAfterHang(path)) => {
986 hung += 1;
987 captured += 1;
988 crate::eprintln!(
989 "{}",
990 format!(
991 "{WARN} vpinball hung and was killed; salvaged frame for {}",
992 path.display()
993 )
994 .yellow()
995 )?;
996 }
997 Ok(CaptureOutcome::Skipped(_)) => {
998 skipped += 1;
999 }
1000 Err(e) => {
1001 failed += 1;
1002 crate::eprintln!("{}", format!("{NOK} {}: {e}", table.path.display()).red())?;
1003 }
1004 }
1005 }
1006
1007 crate::println!(
1008 "Done. {captured} captured ({hung} after a hang), {skipped} skipped, {failed} failed."
1009 )?;
1010 if failed > 0 {
1011 Ok(ExitCode::FAILURE)
1012 } else {
1013 Ok(ExitCode::SUCCESS)
1014 }
1015}
1016
1017fn build_command() -> Command {
1018 Command::new("vpxtool")
1022 .version(GIT_VERSION)
1023 .author("Francis DB")
1024 .about("Terminal based frontend and utilities for Visual Pinball")
1025 .arg_required_else_help(true)
1026 .before_help(format!("Vpxtool {GIT_VERSION}"))
1027 .arg(
1028 Arg::new(ARG_VERBOSE)
1029 .short('v')
1030 .long("verbose")
1031 .action(ArgAction::SetTrue)
1032 .help("Enable verbose logging")
1033 .global(true),
1034 )
1035 .subcommand(
1036 Command::new(CMD_INFO)
1037 .subcommand_required(true)
1038 .about("Vpx table info related commands")
1039 .subcommand(
1040 Command::new(CMD_INFO_SHOW)
1041 .about("Show information for a vpx file")
1042 .arg(
1043 arg!(<VPXPATH> "The path to the vpx file")
1044 .required(true),
1045 ),
1046 )
1047 .subcommand(
1048 Command::new(CMD_INFO_EXTRACT)
1049 .about("Extract information from a vpx file")
1050 .arg(
1051 arg!(<VPXPATH> "The path to the vpx file")
1052 .required(true),
1053 ),
1054 )
1055 .subcommand(
1056 Command::new(CMD_INFO_IMPORT)
1057 .about("Import information into a vpx file")
1058 .arg(
1059 arg!(<VPXPATH> "The path to the vpx file")
1060 .required(true),
1061 ),
1062 )
1063 .subcommand(
1064 Command::new(CMD_INFO_EDIT)
1065 .about("Edit information for a vpx file")
1066 .long_about("Extracts the information from the vpx file into a json file, and opens it in the default editor.")
1067 .arg(
1068 arg!(<VPXPATH> "The path to the vpx file")
1069 .required(true),
1070 ),
1071 )
1072 .subcommand(
1073 Command::new(CMD_INFO_DIFF)
1074 .about("Prints out a diff between the info in the vpx and the sidecar json")
1075 .arg(
1076 arg!(<VPXPATH> "The path to the vpx file")
1077 .required(true),
1078 ),
1079 ),
1080 )
1081 .subcommand(
1082 Command::new(CMD_DIFF)
1083 .about("Prints out a diff between the vbs in the vpx and the sidecar vbs")
1084 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true))
1085 )
1086 .subcommand(
1087 Command::new(CMD_FRONTEND)
1088 .about("Text based frontend for launching vpx files")
1089 .arg(
1090 Arg::new("RECURSIVE")
1091 .short('r')
1092 .long("recursive")
1093 .num_args(0)
1094 .help("Recursively index subdirectories")
1095 .default_value("true"),
1096 )
1097 .arg(
1098 Arg::new(ARG_MAX_DEPTH)
1099 .long("max-depth")
1100 .value_parser(clap::value_parser!(usize))
1101 .help("Maximum directory depth to scan when indexing tables"),
1102 )
1103 )
1104 .subcommand(
1105 Command::new(CMD_INDEX)
1106 .about("Indexes a directory of vpx files")
1107 .arg(
1108 Arg::new("RECURSIVE")
1109 .short('r')
1110 .long("recursive")
1111 .num_args(0)
1112 .help("Recursively index subdirectories")
1113 .default_value("true"),
1114 )
1115 .arg(
1116 Arg::new(ARG_MAX_DEPTH)
1117 .long("max-depth")
1118 .value_parser(clap::value_parser!(usize))
1119 .help("Maximum directory depth to scan when indexing tables"),
1120 )
1121 .arg(
1122 Arg::new("FORCE")
1123 .short('f')
1124 .long("force")
1125 .num_args(0)
1126 .help("Force re-indexing of every table, ignoring cached entries. Use after upgrading vpxtool to pick up newly detected fields (e.g. altsound, altcolor, pup pack)."),
1127 )
1128 .arg(
1129 arg!(<VPXROOTPATH> "The path to the root directory of vpx files. Defaults to what is set up in the vpxtool config file.")
1130 .required(false)
1131 )
1132 .arg(
1133 arg!(<INDEX_FILE> "Where the index will be written. Defaults to VPXROOTPATH/vpxtool_index.json.")
1134 .required(false)
1135 ),
1136 )
1137 .subcommand(
1138 Command::new(CMD_CAPTURE)
1139 .about("Capture a playfield screenshot using vpinball")
1140 .long_about(
1141 "Capture a playfield screenshot next to the table using vpinball's \
1142 attract capture mode. With a VPXPATH a single table is captured, \
1143 otherwise every table in the configured tables folder is captured, \
1144 skipping tables that already have an image unless --force is given.",
1145 )
1146 .arg(
1147 arg!([VPXPATH] "The path to a single vpx file. Defaults to capturing all tables in the configured tables folder.")
1148 .required(false),
1149 )
1150 .arg(
1151 Arg::new(ARG_FORCE)
1152 .short('f')
1153 .long("force")
1154 .num_args(0)
1155 .help("Regenerate the image even if it already exists"),
1156 )
1157 .arg(
1158 Arg::new(ARG_FORMAT)
1159 .long("format")
1160 .value_parser(["jpg", "png", "webp", "qoi"])
1161 .default_value("jpg")
1162 .help("Output image format. jpg (quality 80) is the small, fast default; png/webp are lossless but ~15x larger and slower to encode; qoi keeps vpinball's raw frame (lossless, instant, but ~10MB and barely supported)."),
1163 )
1164 .arg(
1165 Arg::new(ARG_MAX_WIDTH)
1166 .long("max-width")
1167 .value_parser(clap::value_parser!(u32))
1168 .help("Downscale the image so its width does not exceed this many pixels (keeps aspect ratio). Defaults to the native vpinball playfield window resolution."),
1169 )
1170 .arg(
1171 Arg::new(ARG_TIMEOUT)
1172 .long("timeout")
1173 .value_parser(clap::value_parser!(u64))
1174 .default_value("60")
1175 .help("Kill vpinball and skip the table if a capture takes longer than this many seconds (0 disables the timeout). Prevents a hanging table from stalling a batch."),
1176 ),
1177 )
1178 .subcommand(
1179 Command::new(CMD_SCRIPT)
1180 .subcommand_required(true)
1181 .about("Vpx script code related commands")
1182 .subcommand(
1183 Command::new(CMD_SCRIPT_SHOW)
1184 .about("Show a vpx script")
1185 .arg(
1186 arg!(<VPXPATH> "The path to the vpx file")
1187 .required(true),
1188 ),
1189 )
1190 .subcommand(
1191 extract_script_command(CMD_SCRIPT_EXTRACT),
1192 )
1193 .subcommand(
1194 Command::new(CMD_SCRIPT_IMPORT)
1195 .about("Import the table vpx script")
1196 .arg(
1197 arg!(<VPXPATH> "The path to the vpx file")
1198 .required(true),
1199 )
1200 .arg(
1201 arg!([VBSPATH] "The optional path to the vbs file to import. Defaults to the vpx file path with the extension changed to .vbs.")
1202 .required(false),
1203 ),
1204 )
1205 .subcommand(
1206 Command::new(CMD_SCRIPT_EDIT)
1207 .about("Edit the table vpx script")
1208 .arg(
1209 arg!(<VPXPATH> "The path to the vpx file")
1210 .required(true),
1211 ),
1212 )
1213 .subcommand(
1214 Command::new(CMD_SCRIPT_DIFF)
1215 .about("Prints out a diff between the script in the vpx and the sidecar vbs")
1216 .arg(
1217 arg!(<VPXPATH> "The path to the vpx file")
1218 .required(true),
1219 ),
1220 )
1221 .subcommand(
1222 Command::new(CMD_SCRIPT_PATCH)
1223 .about("Patch the table vpx script for typical standalone issues")
1224 .arg(
1225 arg!(<VPXPATH> "The path to the vpx file")
1226 .required(true),
1227 ),
1228 ),
1229 )
1230 .subcommand(
1231 Command::new(CMD_LS)
1232 .about("Show the vpx file contents")
1233 .arg(
1234 arg!(<VPXPATH> "The path to the vpx file")
1235 .required(true),
1236 ),
1237 )
1238 .subcommand(
1239 Command::new(CMD_EXTRACT)
1240 .about("Extracts a vpx file")
1241 .arg(
1242 Arg::new("FORCE")
1243 .short('f')
1244 .long("force")
1245 .num_args(0)
1246 .help("Do not ask for confirmation before overwriting existing files"),
1247 )
1248 .arg(
1249 Arg::new("OUTPUT_DIR")
1250 .short('o')
1251 .long("output-dir")
1252 .num_args(1)
1253 .help("Directory to extract into. Defaults to a folder named after the vpx, next to it."),
1254 )
1255 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1256 )
1257 .subcommand(
1258 extract_script_command(CMD_EXTRACT_VBS),
1259 )
1260 .subcommand(
1261 Command::new(CMD_IMPORT_VBS)
1262 .about("Imports the vbs next to it into a vpx file")
1263 .arg(
1264 arg!(<VPXPATH> "The path(s) to the vpx file(s)")
1265 .required(true)
1266 .num_args(1..),
1267 ),
1268 )
1269 .subcommand(
1270 Command::new(CMD_VERIFY)
1271 .about("Verify the structure of a vpx file")
1272 .arg(
1273 arg!(<VPXPATH> "The path(s) to the vpx file(s)")
1274 .required(true)
1275 .num_args(1..),
1276 ),
1277 )
1278 .subcommand(
1279 Command::new(CMD_LOCK)
1280 .about("Lock a vpx file, preventing edits in vpinball")
1281 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1282 )
1283 .subcommand(
1284 Command::new(CMD_UNLOCK)
1285 .about("Unlock a vpx file")
1286 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1287 )
1288 .subcommand(
1289 Command::new(CMD_LOCK_STATUS)
1290 .about("Show the lock state of a vpx file")
1291 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1292 )
1293 .subcommand(
1294 Command::new(CMD_ASSEMBLE)
1295 .about("Assembles a vpx file")
1296 .arg(
1297 Arg::new("FORCE")
1298 .short('f')
1299 .long("force")
1300 .num_args(0)
1301 .help("Do not ask for confirmation before overwriting existing files"),
1302 )
1303 .arg(arg!(<DIRPATH> "The path to the extracted vpx structure").required(true))
1304 .arg(arg!([VPXPATH] "Optional path of the VPX file to assemble to. Defaults to <DIRPATH>.vpx.")),
1305 )
1306 .subcommand(
1307 Command::new(CMD_PATCH)
1308 .about("Applies a VPURemix System patch to a table")
1309 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true))
1310 .arg(arg!(<PATCHPATH> "The path to the dif file").required(true))
1311 .arg(arg!(<OUTVPXPATH> "The path to the output vpx file. Defaults to <VPXPATH>.patched.vpx").required(false))
1312 )
1313 .subcommand(
1314 Command::new(CMD_NEW)
1315 .about("Creates a minimal empty new vpx file")
1316 .arg(arg!(<VPXPATH> "The path(s) to the vpx file").required(true)),
1317 )
1318 .subcommand(
1319 Command::new(CMD_CONFIG)
1320 .subcommand_required(true)
1321 .about("Vpxtool related config file")
1322 .subcommand(
1323 Command::new(CMD_CONFIG_SETUP)
1324 .about("Sets up the config file"),
1325 )
1326 .subcommand(
1327 Command::new(CMD_CONFIG_PATH)
1328 .about("Shows the current config file path"),
1329 )
1330 .subcommand(
1331 Command::new(CMD_CONFIG_CLEAR)
1332 .about("Clears the current config file"),
1333 )
1334 .subcommand(
1335 Command::new(CMD_CONFIG_SHOW)
1336 .about("Shows the contents of the config file"),
1337 )
1338 .subcommand(
1339 Command::new(CMD_CONFIG_EDIT)
1340 .about("Edits the config file using the default editor"),
1341 )
1342 )
1343 .subcommand(
1344 Command::new(CMD_IMAGES)
1345 .subcommand_required(true)
1346 .about("Vpx image related commands")
1347 .subcommand(
1348 Command::new(CMD_IMAGES_WEBP)
1349 .about("Converts lossless (bmp/png) images in a vpx file to webp")
1350 .arg(
1351 arg!(<VPXPATH> "The path to the vpx file")
1352 .required(true),
1353 ),
1354 )
1355 .subcommand(
1356 Command::new(CMD_IMAGES_LIST)
1357 .about("List the images stored in a vpx file")
1358 .long_about(
1359 "List the images stored in a vpx file as aligned columns: \
1360 NAME, FORMAT, WIDTH, HEIGHT, SIZE (bytes), LINKED (Y/N for \
1361 screenshot-style image links), PATH (original import path). \
1362 PATH is last so awk-style column extraction works on the \
1363 other fields even when paths contain spaces.",
1364 )
1365 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1366 ),
1367 )
1368 .subcommand(
1369 Command::new(CMD_SOUNDS)
1370 .subcommand_required(true)
1371 .about("Vpx sound related commands")
1372 .subcommand(
1373 Command::new(CMD_SOUNDS_LIST)
1374 .about("List the sounds stored in a vpx file")
1375 .long_about(
1376 "List the sounds stored in a vpx file as aligned columns: \
1377 NAME, FORMAT, OUTPUT (table/backglass), PAN, FADE, VOL \
1378 (raw integers from the vpx file, not the signed-percent values \
1379 vpinball shows in its GUI), FREQ (sample rate, Hz), CHAN \
1380 (channel count), LENGTH (seconds, WAV only, blank otherwise), \
1381 SIZE (bytes), PATH (original import path). PATH is last so \
1382 awk-style column extraction works on the other fields even \
1383 when paths contain spaces.",
1384 )
1385 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1386 ),
1387 )
1388 .subcommand(
1389 Command::new(CMD_COLLECTIONS)
1390 .subcommand_required(true)
1391 .about("Vpx collection related commands")
1392 .subcommand(
1393 Command::new(CMD_COLLECTIONS_LIST)
1394 .about("List the collections stored in a vpx file")
1395 .long_about(
1396 "List the collections stored in a vpx file as aligned columns: \
1397 NAME, ITEMS (number of element names in the collection), \
1398 FIRE_EVENTS, STOP_SINGLES, GROUP_ELEMENTS (all Y/N flags).",
1399 )
1400 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1401 ),
1402 )
1403 .subcommand(
1404 Command::new(CMD_MATERIALS)
1405 .subcommand_required(true)
1406 .about("Vpx material related commands")
1407 .subcommand(
1408 Command::new(CMD_MATERIALS_LIST)
1409 .about("List the materials stored in a vpx file")
1410 .long_about(
1411 "List the materials stored in a vpx file as aligned columns: \
1412 NAME, BASE_COLOR (RGB hex), METAL (Y/N), ROUGHNESS (0..1), \
1413 OPACITY (0..1), EDGE (0..1). Supports both the 10.8+ MATR \
1414 format and the pre-10.8 MATE format; columns are the fields \
1415 that exist in both.",
1416 )
1417 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1418 ),
1419 )
1420 .subcommand(
1421 Command::new(CMD_GAMEITEMS)
1422 .subcommand_required(true)
1423 .about("Vpx gameitem (table element) related commands")
1424 .subcommand(
1425 Command::new(CMD_GAMEITEMS_LIST)
1426 .about("List the gameitems stored in a vpx file")
1427 .long_about(
1428 "List the gameitems (vpinball calls them \"elements\" in the \
1429 editor GUI) stored in a vpx file as aligned columns: NAME, \
1430 TYPE, VISIBLE (Y/N/- where '-' means the variant has no \
1431 visibility concept), LOCKED (Y/N/-), LAYER (editor layer name \
1432 if set, otherwise the numeric layer), PART_GROUP, \
1433 PHYSICS_MATERIAL, IMAGES, MATERIALS. IMAGES and MATERIALS are \
1434 '--'-joined to match the format vpinball's editor uses; this \
1435 makes `grep -F -- '--MyTexture'` a reliable way to find every \
1436 item that references a given texture. Empty cells mean either \
1437 the field is not set or the variant has no such field. For \
1438 type counts: `vpxtool gameitems list table.vpx | awk 'NR>1 \
1439 {print $2}' | sort | uniq -c`.",
1440 )
1441 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1442 ),
1443 )
1444 .subcommand(
1445 Command::new(CMD_GAMEDATA)
1446 .subcommand_required(true)
1447 .about("Vpx gamedata related commands")
1448 .subcommand(
1449 Command::new(CMD_GAMEDATA_SHOW)
1450 .about("Show the gamedata for a vpx file")
1451 .arg(
1452 arg!(<VPXPATH> "The path to the vpx file")
1453 .required(true),
1454 ),
1455 ),
1456 )
1457 .subcommand(
1458 Command::new(CMD_DIPSWITCHES)
1459 .subcommand_required(true)
1460 .about("NVRAM file DIP switch related commands")
1461 .subcommand(
1462 Command::new(CMD_DIPSWITCHES_SHOW)
1463 .about("Show the DIP switches for a nvram file")
1464 .arg(
1465 arg!(<NVRAMPATH> "The path to the nvram file")
1466 .required(true),
1467 ),
1468 ),
1469 )
1470 .subcommand(
1471 Command::new(CMD_NVRAM)
1472 .subcommand_required(true)
1473 .about("PinMAME NVRAM related commands")
1474 .subcommand(
1475 Command::new(CMD_NVRAM_SHOW)
1476 .about("Resolve a PinMAME NVRAM file to JSON")
1477 .long_about(
1478 "Resolve a PinMAME NVRAM file to JSON using the pinmame-nvram maps. \
1479 PATH may be a .vpx (the .nv is located via the configured/global \
1480 pinmame folders), a .nv (resolved directly), or a rom .zip (the \
1481 sibling ../nvram/<stem>.nv is used).",
1482 )
1483 .arg(arg!(<PATH> "Path to a .vpx, .nv, or rom .zip file").required(true)),
1484 ),
1485 )
1486 .subcommand(
1487 Command::new(CMD_SCORES)
1488 .subcommand_required(true)
1489 .about("Table high-score related commands")
1490 .subcommand(
1491 Command::new(CMD_SCORES_SHOW)
1492 .about("Show high scores for a table")
1493 .long_about(
1494 "Show the high-score entries stored for a table. PATH accepts a \
1495 .vpx, a .nv, or a rom .zip exactly like `nvram show`. PinMAME \
1496 tables (.nv/.zip or .vpx with a ROM) are resolved through the \
1497 pinmame-nvram maps. For rom-less .vpx tables, three non-PinMAME \
1498 backends are probed in order: `VPReg.ini` (in `user/` first, \
1499 then sibling) keyed by every distinct section name the script \
1500 references in `(Load|Save)Value(<arg>, \"HighScore...\")` calls \
1501 (so cGameName, TableName, MyTable, hardcoded literals, ... all \
1502 resolve); a `<cGameName>_glf.ini` sibling (GLF framework); and \
1503 any `user/*.txt` / `*.txt` files containing a 5-scores-then-5- \
1504 initials block or a single-hisc all-integer file (EM tables \
1505 using Black's Highscore routines).\n\
1506 \n\
1507 Default format is an aligned LABEL / INITIALS / SCORE table \
1508 with comma-grouped scores. `--format tsv` emits tab-separated \
1509 rows with raw integer scores for scripting (label and initials \
1510 can contain spaces, so a tab-delimited format is the reliable \
1511 way to split columns).",
1512 )
1513 .arg(arg!(<PATH> "Path to a .vpx, .nv, or rom .zip file").required(true))
1514 .arg(
1515 Arg::new("FORMAT")
1516 .long("format")
1517 .value_parser(["table", "tsv", "pinemhi"])
1518 .default_value("table")
1519 .help("Output format: 'table' (aligned columns, default), 'tsv' (tab-separated, raw scores), or 'pinemhi' (section layout similar to PINemHi's output)"),
1520 ),
1521 ),
1522 )
1523 .subcommand(
1524 Command::new(CMD_ROMNAME)
1525 .about("Prints the PinMAME ROM name from a vpx file")
1526 .long_about("Extracts the PinMAME ROM name from a vpx file by searching for specific patterns in the table script. If the table is not PinMAME based, no output is produced.")
1527 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true)),
1528 )
1529 .subcommand(
1530 Command::new(CMD_EXPORT)
1531 .subcommand_required(true)
1532 .about("Export a vpx table to a 3D model format")
1533 .subcommand(
1534 Command::new(CMD_EXPORT_OBJ)
1535 .about("Export the table as a Wavefront OBJ + MTL (with images/)")
1536 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true))
1537 .arg(
1538 Arg::new("OUTPUT_DIR")
1539 .short('o')
1540 .long("output-dir")
1541 .num_args(1)
1542 .help("Output directory. Defaults to <stem>_obj/ next to the vpx file."),
1543 )
1544 .arg(
1545 Arg::new("UNITS")
1546 .long("units")
1547 .num_args(1)
1548 .value_parser(["vpu", "mm", "cm", "m"])
1549 .default_value("m")
1550 .help("Output units for vertex positions"),
1551 )
1552 .arg(
1553 Arg::new("VPINBALL_STRICT")
1554 .long("vpinball-strict")
1555 .num_args(0)
1556 .help("Match vpinball's own OBJ exporter (no textures, raw VPU, duplicate newmtl blocks). Overrides --units."),
1557 ),
1558 )
1559 .subcommand(
1560 Command::new(CMD_EXPORT_VPXZ)
1561 .about("Export the table as a .vpxz archive for the Visual Pinball mobile app")
1562 .long_about("Bundles the vpx and its sidecar files (.vbs, .ini, .directb2s, .png/.jpg) into a single .vpxz archive (a renamed zip). When the table is PinMAME-based, also bundles the matching rom zip from the configured pinmame folder unless --no-rom is set.")
1563 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true))
1564 .arg(
1565 Arg::new("OUTPUT")
1566 .short('o')
1567 .long("output")
1568 .num_args(1)
1569 .help("Output .vpxz path. Defaults to <stem>.vpxz one folder up from the vpx, so re-runs don't recursively pick up the previous output."),
1570 )
1571 .arg(
1572 Arg::new("NO_ROM")
1573 .long("no-rom")
1574 .num_args(0)
1575 .help("Do not bundle the matching PinMAME rom zip"),
1576 )
1577 .arg(
1578 Arg::new("FORCE")
1579 .short('f')
1580 .long("force")
1581 .num_args(0)
1582 .help("Overwrite the output file if it already exists"),
1583 ),
1584 )
1585 .subcommand(
1586 Command::new(CMD_EXPORT_GLTF)
1587 .about("Export the table as a glTF or GLB file")
1588 .arg(arg!(<VPXPATH> "The path to the vpx file").required(true))
1589 .arg(
1590 Arg::new("OUTPUT_DIR")
1591 .short('o')
1592 .long("output-dir")
1593 .num_args(1)
1594 .help("Output directory. Defaults to <stem>_gltf/ next to the vpx file."),
1595 )
1596 .arg(
1597 Arg::new("FORMAT")
1598 .long("format")
1599 .num_args(1)
1600 .value_parser(["glb", "gltf"])
1601 .default_value("glb")
1602 .help("Output format: glb (single binary) or gltf (json + .bin sidecar)"),
1603 )
1604 .arg(
1605 Arg::new("UNITS")
1606 .long("units")
1607 .num_args(1)
1608 .value_parser(["vpu", "mm", "cm", "m"])
1609 .default_value("m")
1610 .help("Output units for vertex positions"),
1611 )
1612 .arg(
1613 Arg::new("INVISIBLE")
1614 .long("invisible")
1615 .num_args(0)
1616 .help("Include invisible items using the KHR_node_visibility extension"),
1617 ),
1618 ),
1619 )
1620}
1621
1622fn parse_units(s: &str) -> ExportUnits {
1623 match s {
1624 "vpu" => ExportUnits::Vpu,
1625 "mm" => ExportUnits::Mm,
1626 "cm" => ExportUnits::Cm,
1627 "m" => ExportUnits::M,
1628 _ => unreachable!("clap value_parser restricts this"),
1629 }
1630}
1631
1632fn handle_export_obj(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
1633 let path = sub_matches
1634 .get_one::<String>("VPXPATH")
1635 .map(|s| s.as_str())
1636 .unwrap_or_default();
1637 let expanded_path = path_exists(path)?;
1638 let units = parse_units(sub_matches.get_one::<String>("UNITS").unwrap());
1639 let strict = sub_matches.get_flag("VPINBALL_STRICT");
1640 let output_dir = sub_matches
1641 .get_one::<String>("OUTPUT_DIR")
1642 .map(PathBuf::from);
1643
1644 let stem = expanded_path
1645 .file_stem()
1646 .and_then(|s| s.to_str())
1647 .ok_or_else(|| {
1648 io::Error::new(
1649 io::ErrorKind::InvalidInput,
1650 "vpx path has no usable file stem",
1651 )
1652 })?
1653 .to_string();
1654 let parent = expanded_path
1655 .parent()
1656 .map(|p| p.to_path_buf())
1657 .unwrap_or_else(|| PathBuf::from("."));
1658 let out_dir = output_dir.unwrap_or_else(|| parent.join(format!("{stem}_obj")));
1659 if out_dir.exists() && !out_dir.is_dir() {
1660 return fail(format!(
1661 "Output path exists and is not a directory: {}",
1662 out_dir.display()
1663 ));
1664 }
1665 std::fs::create_dir_all(&out_dir)?;
1666 let obj_path = out_dir.join(format!("{stem}.obj"));
1667
1668 let mut options = if strict {
1669 ObjExportOptions::vpinball_strict()
1670 } else {
1671 ObjExportOptions::default()
1672 };
1673 if !strict {
1674 options.units = units;
1675 }
1676
1677 crate::println!("Reading {}", expanded_path.display())?;
1678 let vpx = vpx::read(&expanded_path)?;
1679 crate::println!(
1680 "Exporting OBJ to {} (units: {:?}, mode: {})",
1681 obj_path.display(),
1682 options.units,
1683 if strict { "vpinball-strict" } else { "default" },
1684 )?;
1685 export_obj(&vpx, &obj_path, &RealFileSystem, &options)?;
1686 crate::println!("Done.")?;
1687 Ok(ExitCode::SUCCESS)
1688}
1689
1690fn handle_export_gltf(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
1691 let path = sub_matches
1692 .get_one::<String>("VPXPATH")
1693 .map(|s| s.as_str())
1694 .unwrap_or_default();
1695 let expanded_path = path_exists(path)?;
1696 let format = match sub_matches.get_one::<String>("FORMAT").unwrap().as_str() {
1697 "glb" => GltfFormat::Glb,
1698 "gltf" => GltfFormat::Gltf,
1699 _ => unreachable!("clap value_parser restricts this"),
1700 };
1701 let units = parse_units(sub_matches.get_one::<String>("UNITS").unwrap());
1702 let export_invisible = sub_matches.get_flag("INVISIBLE");
1703 let output_dir = sub_matches
1704 .get_one::<String>("OUTPUT_DIR")
1705 .map(PathBuf::from);
1706
1707 let stem = expanded_path
1708 .file_stem()
1709 .and_then(|s| s.to_str())
1710 .ok_or_else(|| {
1711 io::Error::new(
1712 io::ErrorKind::InvalidInput,
1713 "vpx path has no usable file stem",
1714 )
1715 })?
1716 .to_string();
1717 let parent = expanded_path
1718 .parent()
1719 .map(|p| p.to_path_buf())
1720 .unwrap_or_else(|| PathBuf::from("."));
1721 let out_dir = output_dir.unwrap_or_else(|| parent.join(format!("{stem}_gltf")));
1722 if out_dir.exists() && !out_dir.is_dir() {
1723 return fail(format!(
1724 "Output path exists and is not a directory: {}",
1725 out_dir.display()
1726 ));
1727 }
1728 std::fs::create_dir_all(&out_dir)?;
1729 let ext = match format {
1730 GltfFormat::Glb => "glb",
1731 GltfFormat::Gltf => "gltf",
1732 };
1733 let output_path = out_dir.join(format!("{stem}.{ext}"));
1734
1735 let options = GltfExportOptions {
1736 format,
1737 export_invisible_items: export_invisible,
1738 units,
1739 };
1740
1741 crate::println!("Reading {}", expanded_path.display())?;
1742 let vpx = vpx::read(&expanded_path)?;
1743 crate::println!(
1744 "Exporting {} to {} (units: {:?}{})",
1745 match format {
1746 GltfFormat::Glb => "GLB",
1747 GltfFormat::Gltf => "glTF",
1748 },
1749 output_path.display(),
1750 options.units,
1751 if export_invisible {
1752 ", including invisible items"
1753 } else {
1754 ""
1755 },
1756 )?;
1757 export_gltf(&vpx, &output_path, &RealFileSystem, &options)?;
1758 crate::println!("Done.")?;
1759 Ok(ExitCode::SUCCESS)
1760}
1761
1762fn handle_export_vpxz(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
1763 let path = sub_matches
1764 .get_one::<String>("VPXPATH")
1765 .map(|s| s.as_str())
1766 .unwrap_or_default();
1767 let expanded_path = path_exists(path)?;
1768 let bundle_rom = !sub_matches.get_flag("NO_ROM");
1769 let force = sub_matches.get_flag("FORCE");
1770 let output = sub_matches.get_one::<String>("OUTPUT").map(PathBuf::from);
1771
1772 let output_path = match output {
1773 Some(p) => p,
1774 None => crate::vpxz::default_output_path(&expanded_path)?,
1775 };
1776
1777 if output_path.exists() {
1778 if output_path.is_dir() {
1779 return fail(format!(
1780 "Output path exists and is a directory: {}",
1781 output_path.display()
1782 ));
1783 }
1784 if !force {
1785 let confirmed = confirm(
1786 format!("\"{}\" already exists.", output_path.display()),
1787 "Do you want to overwrite it?".to_string(),
1788 )?;
1789 if !confirmed {
1790 crate::println!("Aborted")?;
1791 return Ok(ExitCode::FAILURE);
1792 }
1793 }
1794 }
1795
1796 let loaded_config = config::load_config()?;
1797 let config = loaded_config.as_ref().map(|c| &c.1);
1798
1799 let rom_name = indexer::get_romname_from_vpx(&expanded_path)?;
1800 let rom_zip = if bundle_rom {
1801 let configured = config.and_then(|c| c.configured_pinmame_folder());
1802 let global = config.map(|c| c.global_pinmame_folder());
1803 crate::vpxz::find_rom_zip(&expanded_path, configured.as_deref(), global.as_deref())?
1804 } else {
1805 None
1806 };
1807
1808 let exclude_globs: Vec<String> = config
1809 .map(|c| c.vpxz_excludes.clone())
1810 .unwrap_or_else(config::default_vpxz_excludes);
1811
1812 let parent = expanded_path
1813 .parent()
1814 .map(|p| p.display().to_string())
1815 .unwrap_or_default();
1816 crate::println!("Scanning {parent} ...")?;
1817
1818 let pb = ProgressBar::hidden();
1819 pb.set_style(
1820 ProgressStyle::with_template("{spinner:.green} [{bar:.cyan/blue}] {pos}/{human_len} {msg}")
1821 .unwrap(),
1822 );
1823 pb.set_message("bundling");
1824 let progress = ProgressBarProgress::new(pb);
1825
1826 let report = crate::vpxz::export_vpxz(
1827 &expanded_path,
1828 &output_path,
1829 &crate::vpxz::VpxzExportOptions {
1830 exclude_globs: &exclude_globs,
1831 rom_zip: rom_zip.as_deref(),
1832 progress: Some(&progress),
1833 },
1834 )?;
1835
1836 if !report.excluded.is_empty() {
1837 crate::println!("Excluded {} files:", report.excluded.len())?;
1838 for (path, reason) in &report.excluded {
1839 crate::println!(" {path} [{reason}]")?;
1840 }
1841 }
1842 if let Some(rom_path) = &report.injected_rom {
1843 crate::println!("Injected rom from {}", rom_path.display())?;
1844 }
1845 if bundle_rom
1846 && let Some(rom_name) = rom_name.as_deref()
1847 && !report.rom_bundled(rom_name)
1848 {
1849 crate::println!(
1850 "{}",
1851 format!("Note: rom '{rom_name}' not found; not bundled.").truecolor(255, 125, 0)
1852 )?;
1853 }
1854 crate::println!(
1855 "Wrote {} ({} included, {} excluded)",
1856 report.output.display(),
1857 report.included.len(),
1858 report.excluded.len()
1859 )?;
1860 Ok(ExitCode::SUCCESS)
1861}
1862
1863enum NvramResolveError {
1872 NotPinmame(PathBuf),
1873 NoNvramFor(PathBuf),
1874 NoNvramNextToZip(PathBuf),
1875 InvalidZipStem(PathBuf),
1876 UnsupportedExtension(PathBuf),
1877}
1878
1879impl NvramResolveError {
1880 fn fail(self) -> io::Result<ExitCode> {
1881 match self {
1882 NvramResolveError::NotPinmame(p) => {
1883 fail(format!("Table {} is not PinMAME-based", p.display()))
1884 }
1885 NvramResolveError::NoNvramFor(p) => fail(format!(
1886 "No nvram file found for {} - try launching the table once",
1887 p.display()
1888 )),
1889 NvramResolveError::NoNvramNextToZip(p) => fail(format!(
1890 "No nvram file found next to rom zip {}",
1891 p.display()
1892 )),
1893 NvramResolveError::InvalidZipStem(p) => {
1894 fail(format!("rom zip has no usable file stem: {}", p.display()))
1895 }
1896 NvramResolveError::UnsupportedExtension(p) => fail(format!(
1897 "Unsupported file type: {} (expected .vpx, .nv, or rom .zip)",
1898 p.display()
1899 )),
1900 }
1901 }
1902}
1903
1904fn resolve_nvram_path(expanded_path: &Path) -> io::Result<Result<PathBuf, NvramResolveError>> {
1905 let nvram_path = match expanded_path
1906 .extension()
1907 .and_then(OsStr::to_str)
1908 .map(str::to_ascii_lowercase)
1909 .as_deref()
1910 {
1911 Some("nv") => expanded_path.to_path_buf(),
1912 Some("zip") => {
1913 let Some(stem) = expanded_path.file_stem().and_then(OsStr::to_str) else {
1914 return Ok(Err(NvramResolveError::InvalidZipStem(
1915 expanded_path.to_path_buf(),
1916 )));
1917 };
1918 let candidate = expanded_path
1919 .parent()
1920 .and_then(Path::parent)
1921 .map(|p| p.join("nvram").join(format!("{stem}.nv")));
1922 match candidate.filter(|p| p.is_file()) {
1923 Some(p) => p,
1924 None => {
1925 return Ok(Err(NvramResolveError::NoNvramNextToZip(
1926 expanded_path.to_path_buf(),
1927 )));
1928 }
1929 }
1930 }
1931 Some("vpx") => {
1932 if indexer::get_romname_from_vpx(expanded_path)?.is_none() {
1933 return Ok(Err(NvramResolveError::NotPinmame(
1934 expanded_path.to_path_buf(),
1935 )));
1936 }
1937 let loaded_config = config::load_config()?;
1938 let config = loaded_config.as_ref().map(|c| &c.1);
1939 let configured = config.and_then(|c| c.configured_pinmame_folder());
1940 let global = config.map(|c| c.global_pinmame_folder());
1941 match indexer::find_nvram_for_vpx(
1942 expanded_path,
1943 configured.as_deref(),
1944 global.as_deref(),
1945 )? {
1946 Some(p) => p,
1947 None => {
1948 return Ok(Err(NvramResolveError::NoNvramFor(
1949 expanded_path.to_path_buf(),
1950 )));
1951 }
1952 }
1953 }
1954 _ => {
1955 return Ok(Err(NvramResolveError::UnsupportedExtension(
1956 expanded_path.to_path_buf(),
1957 )));
1958 }
1959 };
1960 Ok(Ok(nvram_path))
1961}
1962
1963fn handle_nvram_show(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
1964 let path = sub_matches
1965 .get_one::<String>("PATH")
1966 .map(|s| s.as_str())
1967 .unwrap_or_default();
1968 let expanded_path = path_exists(path)?;
1969
1970 let nvram_path = match resolve_nvram_path(&expanded_path)? {
1971 Ok(p) => p,
1972 Err(e) => return e.fail(),
1973 };
1974
1975 match pinmame_nvram::resolve::resolve(&nvram_path) {
1976 Ok(Some(resolved)) => {
1977 let json = serde_json::to_string_pretty(&resolved)
1978 .map_err(|e| io::Error::other(format!("Failed to serialize nvram json: {e}")))?;
1979 crate::println!("{json}")?;
1980 Ok(ExitCode::SUCCESS)
1981 }
1982 Ok(None) => fail(format!("No pinmame-nvram map for {}", nvram_path.display())),
1983 Err(e) => fail(format!(
1984 "Failed to resolve nvram {}: {e}",
1985 nvram_path.display()
1986 )),
1987 }
1988}
1989
1990fn handle_scores_show(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
1991 let path = sub_matches
1992 .get_one::<String>("PATH")
1993 .map(|s| s.as_str())
1994 .unwrap_or_default();
1995 let format = sub_matches
1996 .get_one::<String>("FORMAT")
1997 .map(|s| s.as_str())
1998 .unwrap_or("table");
1999 let expanded_path = path_exists(path)?;
2000
2001 let sections: Vec<crate::scores::Section> = match resolve_nvram_path(&expanded_path)? {
2004 Ok(nvram_path) => match pinmame_nvram::resolve::resolve(&nvram_path) {
2005 Ok(Some(r)) => crate::scores::extract_sections(&r),
2006 Ok(None) => {
2007 return fail(format!("No pinmame-nvram map for {}", nvram_path.display()));
2008 }
2009 Err(e) => {
2010 return fail(format!(
2011 "Failed to resolve nvram {}: {e}",
2012 nvram_path.display()
2013 ));
2014 }
2015 },
2016 Err(prior) => match try_non_pinmame_fallback(&expanded_path, &prior)? {
2017 Some(sections) => sections,
2018 None => match &prior {
2019 NvramResolveError::NotPinmame(p) => {
2023 return fail(format!(
2024 "Could not find any high scores for {}: tried PinMAME \
2025 nvram, VPReg.ini, GLF, and EM-style .txt files",
2026 p.display()
2027 ));
2028 }
2029 _ => return prior.fail(),
2030 },
2031 },
2032 };
2033
2034 render_sections(§ions, format)
2035}
2036
2037fn try_non_pinmame_fallback(
2049 expanded_path: &Path,
2050 prior_err: &NvramResolveError,
2051) -> io::Result<Option<Vec<crate::scores::Section>>> {
2052 let is_vpx = expanded_path
2053 .extension()
2054 .and_then(OsStr::to_str)
2055 .is_some_and(|e| e.eq_ignore_ascii_case("vpx"));
2056 let should_try = is_vpx
2057 && matches!(
2058 prior_err,
2059 NvramResolveError::NotPinmame(_) | NvramResolveError::NoNvramFor(_)
2060 );
2061 if !should_try {
2062 return Ok(None);
2063 }
2064 let vpx_parent = expanded_path.parent().unwrap_or(Path::new("."));
2065
2066 let vpreg_keys = indexer::get_vpreg_section_keys_from_vpx(expanded_path)?;
2073 let vpreg_candidates = [
2074 vpx_parent.join("user").join("VPReg.ini"),
2075 vpx_parent.join("VPReg.ini"),
2076 ];
2077 for candidate in &vpreg_candidates {
2078 if !candidate.is_file() {
2079 continue;
2080 }
2081 for key in &vpreg_keys {
2082 match crate::scores::vpreg::read_sections(candidate, key) {
2083 Ok(sections) => return Ok(Some(sections)),
2084 Err(crate::scores::vpreg::LookupError::SectionNotFound)
2085 | Err(crate::scores::vpreg::LookupError::SectionHasNoScores) => continue,
2086 Err(crate::scores::vpreg::LookupError::ParseFailed(msg)) => {
2087 return Err(io::Error::other(format!(
2088 "Failed to parse {}: {msg}",
2089 candidate.display()
2090 )));
2091 }
2092 }
2093 }
2094 }
2095
2096 let game_name = indexer::get_gamename_from_vpx(expanded_path)?;
2099 if let Some(ref game_name) = game_name {
2100 let glf_path = vpx_parent.join(format!("{game_name}_glf.ini"));
2101 if glf_path.is_file() {
2102 match crate::scores::glf::read_sections(&glf_path) {
2103 Ok(sections) => return Ok(Some(sections)),
2104 Err(crate::scores::glf::LookupError::NoHighScoresSection)
2107 | Err(crate::scores::glf::LookupError::EmptyHighScores) => {}
2108 Err(crate::scores::glf::LookupError::ParseFailed(msg)) => {
2109 return Err(io::Error::other(format!(
2110 "Failed to parse {}: {msg}",
2111 glf_path.display()
2112 )));
2113 }
2114 }
2115 }
2116 }
2117
2118 if let Some(sections) = try_emhs_glob(vpx_parent)? {
2124 return Ok(Some(sections));
2125 }
2126
2127 Ok(None)
2128}
2129
2130fn try_emhs_glob(vpx_parent: &Path) -> io::Result<Option<Vec<crate::scores::Section>>> {
2134 for dir in [vpx_parent.join("user"), vpx_parent.to_path_buf()] {
2135 let Ok(entries) = std::fs::read_dir(&dir) else {
2136 continue;
2137 };
2138 let mut txts: Vec<PathBuf> = entries
2141 .filter_map(Result::ok)
2142 .map(|e| e.path())
2143 .filter(|p| {
2144 p.is_file()
2145 && p.extension()
2146 .and_then(OsStr::to_str)
2147 .is_some_and(|e| e.eq_ignore_ascii_case("txt"))
2148 })
2149 .collect();
2150 txts.sort();
2151 for candidate in txts {
2152 match crate::scores::emhs::read_sections(&candidate) {
2153 Ok(sections) => return Ok(Some(sections)),
2154 Err(crate::scores::emhs::LookupError::PatternNotFound) => continue,
2155 Err(crate::scores::emhs::LookupError::ReadFailed(msg)) => {
2156 return Err(io::Error::other(format!(
2157 "Failed to read {}: {msg}",
2158 candidate.display()
2159 )));
2160 }
2161 }
2162 }
2163 }
2164 Ok(None)
2165}
2166
2167fn render_sections(sections: &[crate::scores::Section], format: &str) -> io::Result<ExitCode> {
2170 match format {
2171 "tsv" => {
2172 crate::println!("{}", crate::scores::HEADERS.join("\t"))?;
2177 for section in sections {
2178 for row in §ion.rows {
2179 crate::println!("{}", row.join("\t"))?;
2180 }
2181 }
2182 }
2183 "pinemhi" => {
2184 #[cfg(not(windows))]
2191 let rendered = if let Some(sys) = readable_system_locale() {
2192 crate::scores::render_pinemhi(sections, &sys)
2193 } else {
2194 crate::scores::render_pinemhi(sections, &num_format::Locale::en)
2195 };
2196 #[cfg(windows)]
2197 let rendered = crate::scores::render_pinemhi(sections, &num_format::Locale::en);
2198 crate::print!("{}", rendered)?;
2199 }
2200 _ => {
2201 let mut rows: Vec<Vec<String>> = sections
2204 .iter()
2205 .flat_map(|s| s.rows.iter().cloned())
2206 .collect();
2207 #[cfg(not(windows))]
2208 if let Some(sys) = readable_system_locale() {
2209 crate::scores::pretty_score_column(&mut rows, &sys);
2210 } else {
2211 crate::scores::pretty_score_column(&mut rows, &num_format::Locale::en);
2212 }
2213 #[cfg(windows)]
2214 crate::scores::pretty_score_column(&mut rows, &num_format::Locale::en);
2215 let visible_headers = ["LABEL", "INITIALS", "SCORE"];
2216 let aligns = [ColAlign::Left, ColAlign::Left, ColAlign::Right];
2217 let visible_rows: Vec<Vec<String>> = rows
2218 .into_iter()
2219 .map(|mut r| {
2220 r.truncate(3);
2221 r
2222 })
2223 .collect();
2224 print_aligned_table(&visible_headers, &aligns, &visible_rows)?;
2225 }
2226 }
2227 Ok(ExitCode::SUCCESS)
2228}
2229
2230#[cfg(not(windows))]
2241fn readable_system_locale() -> Option<num_format::SystemLocale> {
2242 let sys = num_format::SystemLocale::default().ok()?;
2243 if sys.separator().is_empty() {
2244 return None;
2245 }
2246 Some(sys)
2247}
2248
2249#[derive(Clone, Copy)]
2253enum ColAlign {
2254 Left,
2255 Right,
2256}
2257
2258fn print_aligned_table(
2259 headers: &[&str],
2260 aligns: &[ColAlign],
2261 rows: &[Vec<String>],
2262) -> io::Result<()> {
2263 assert_eq!(headers.len(), aligns.len());
2264 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
2265 for row in rows {
2266 assert_eq!(row.len(), headers.len());
2267 for (i, cell) in row.iter().enumerate() {
2268 if cell.len() > widths[i] {
2269 widths[i] = cell.len();
2270 }
2271 }
2272 }
2273 let mut buf = String::new();
2274 let mut emit = |cells: &[&str]| -> io::Result<()> {
2275 buf.clear();
2276 let last = cells.len() - 1;
2277 for (i, cell) in cells.iter().enumerate() {
2278 if i > 0 {
2279 buf.push_str(" ");
2280 }
2281 if i == last {
2282 buf.push_str(cell);
2283 } else {
2284 match aligns[i] {
2285 ColAlign::Left => buf.push_str(&format!("{:<w$}", cell, w = widths[i])),
2286 ColAlign::Right => buf.push_str(&format!("{:>w$}", cell, w = widths[i])),
2287 }
2288 }
2289 }
2290 crate::println!("{}", buf)
2291 };
2292 emit(headers)?;
2293 for row in rows {
2294 let refs: Vec<&str> = row.iter().map(String::as_str).collect();
2295 emit(&refs)?;
2296 }
2297 Ok(())
2298}
2299
2300fn handle_images_list(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
2301 let path = sub_matches
2302 .get_one::<String>("VPXPATH")
2303 .map(|s| s.as_str())
2304 .unwrap_or_default();
2305 let expanded_path = path_exists(path)?;
2306 let mut vpx_file = vpx::open(&expanded_path)?;
2307 let images = vpx_file.read_images()?;
2308
2309 let rows: Vec<Vec<String>> = images
2310 .iter()
2311 .map(|image| {
2312 let size_bytes = if let Some(jpeg) = &image.jpeg {
2313 jpeg.data.len()
2314 } else if let Some(bits) = &image.bits {
2315 bits.lzw_compressed_data.len()
2316 } else {
2317 0
2318 };
2319 vec![
2320 image.name.clone(),
2321 image.ext(),
2322 image.width.to_string(),
2323 image.height.to_string(),
2324 size_bytes.to_string(),
2325 if image.is_link() { "Y" } else { "N" }.to_string(),
2326 image.path.clone(),
2327 ]
2328 })
2329 .collect();
2330
2331 let headers = [
2332 "NAME", "FORMAT", "WIDTH", "HEIGHT", "SIZE", "LINKED", "PATH",
2333 ];
2334 let aligns = [
2335 ColAlign::Left,
2336 ColAlign::Left,
2337 ColAlign::Right,
2338 ColAlign::Right,
2339 ColAlign::Right,
2340 ColAlign::Left,
2341 ColAlign::Left,
2342 ];
2343 print_aligned_table(&headers, &aligns, &rows)?;
2344 Ok(ExitCode::SUCCESS)
2345}
2346
2347fn handle_sounds_list(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
2348 let path = sub_matches
2349 .get_one::<String>("VPXPATH")
2350 .map(|s| s.as_str())
2351 .unwrap_or_default();
2352 let expanded_path = path_exists(path)?;
2353 let mut vpx_file = vpx::open(&expanded_path)?;
2354 let sounds = vpx_file.read_sounds()?;
2355
2356 let rows: Vec<Vec<String>> = sounds
2357 .iter()
2358 .map(|sound| {
2359 let ext = sound
2360 .path
2361 .rsplit('.')
2362 .next()
2363 .filter(|e| !e.contains(['/', '\\']))
2364 .unwrap_or("")
2365 .to_ascii_lowercase();
2366 let is_wav = ext.is_empty() || ext == "wav";
2367 let format = if ext.is_empty() {
2368 "wav".to_string()
2369 } else {
2370 ext
2371 };
2372 let output = match sound.output_target {
2373 vpin::vpx::sound::OutputTarget::Table => "table",
2374 vpin::vpx::sound::OutputTarget::Backglass => "backglass",
2375 };
2376 let length = if is_wav && sound.wave_form.avg_bytes_per_sec > 0 {
2380 format!(
2381 "{:.2}",
2382 sound.data.len() as f64 / sound.wave_form.avg_bytes_per_sec as f64
2383 )
2384 } else {
2385 String::new()
2386 };
2387 vec![
2388 sound.name.clone(),
2389 format,
2390 output.to_string(),
2391 sound.balance.to_string(),
2392 sound.fade.to_string(),
2393 sound.volume.to_string(),
2394 sound.wave_form.samples_per_sec.to_string(),
2395 sound.wave_form.channels.to_string(),
2396 length,
2397 sound.data.len().to_string(),
2398 sound.path.clone(),
2399 ]
2400 })
2401 .collect();
2402
2403 let headers = [
2404 "NAME", "FORMAT", "OUTPUT", "PAN", "FADE", "VOL", "FREQ", "CHAN", "LENGTH", "SIZE", "PATH",
2405 ];
2406 let aligns = [
2407 ColAlign::Left, ColAlign::Left, ColAlign::Left, ColAlign::Right, ColAlign::Right, ColAlign::Right, ColAlign::Right, ColAlign::Right, ColAlign::Right, ColAlign::Right, ColAlign::Left, ];
2419 print_aligned_table(&headers, &aligns, &rows)?;
2420 Ok(ExitCode::SUCCESS)
2421}
2422
2423fn handle_collections_list(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
2424 let path = sub_matches
2425 .get_one::<String>("VPXPATH")
2426 .map(|s| s.as_str())
2427 .unwrap_or_default();
2428 let expanded_path = path_exists(path)?;
2429 let mut vpx_file = vpx::open(&expanded_path)?;
2430 let collections = vpx_file.read_collections()?;
2431
2432 let rows: Vec<Vec<String>> = collections
2433 .iter()
2434 .map(|c| {
2435 vec![
2436 c.name.clone(),
2437 c.items.len().to_string(),
2438 if c.fire_events { "Y" } else { "N" }.to_string(),
2439 if c.stop_single_events { "Y" } else { "N" }.to_string(),
2440 if c.group_elements { "Y" } else { "N" }.to_string(),
2441 ]
2442 })
2443 .collect();
2444
2445 let headers = [
2446 "NAME",
2447 "ITEMS",
2448 "FIRE_EVENTS",
2449 "STOP_SINGLES",
2450 "GROUP_ELEMENTS",
2451 ];
2452 let aligns = [
2453 ColAlign::Left,
2454 ColAlign::Right,
2455 ColAlign::Left,
2456 ColAlign::Left,
2457 ColAlign::Left,
2458 ];
2459 print_aligned_table(&headers, &aligns, &rows)?;
2460 Ok(ExitCode::SUCCESS)
2461}
2462
2463fn handle_materials_list(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
2464 let path = sub_matches
2465 .get_one::<String>("VPXPATH")
2466 .map(|s| s.as_str())
2467 .unwrap_or_default();
2468 let expanded_path = path_exists(path)?;
2469 let mut vpx_file = vpx::open(&expanded_path)?;
2470 let gamedata = vpx_file.read_gamedata()?;
2471
2472 let rows: Vec<Vec<String>> = if let Some(materials) = gamedata.materials.as_ref() {
2476 materials
2477 .iter()
2478 .map(|m| {
2479 vec![
2480 m.name.clone(),
2481 format!(
2482 "#{:02X}{:02X}{:02X}",
2483 m.base_color.r, m.base_color.g, m.base_color.b
2484 ),
2485 if m.type_ == vpin::vpx::material::MaterialType::Metal {
2486 "Y"
2487 } else {
2488 "N"
2489 }
2490 .to_string(),
2491 format!("{:.3}", m.roughness),
2492 format!("{:.3}", m.opacity),
2493 format!("{:.3}", m.edge),
2494 ]
2495 })
2496 .collect()
2497 } else {
2498 gamedata
2499 .materials_old
2500 .iter()
2501 .map(|m| {
2502 vec![
2503 m.name.clone(),
2504 format!(
2505 "#{:02X}{:02X}{:02X}",
2506 m.base_color.r, m.base_color.g, m.base_color.b
2507 ),
2508 if m.is_metal { "Y" } else { "N" }.to_string(),
2509 format!("{:.3}", m.roughness),
2510 format!("{:.3}", m.opacity),
2511 format!("{:.3}", m.edge),
2512 ]
2513 })
2514 .collect()
2515 };
2516
2517 let headers = [
2518 "NAME",
2519 "BASE_COLOR",
2520 "METAL",
2521 "ROUGHNESS",
2522 "OPACITY",
2523 "EDGE",
2524 ];
2525 let aligns = [
2526 ColAlign::Left,
2527 ColAlign::Left,
2528 ColAlign::Left,
2529 ColAlign::Right,
2530 ColAlign::Right,
2531 ColAlign::Right,
2532 ];
2533 print_aligned_table(&headers, &aligns, &rows)?;
2534 Ok(ExitCode::SUCCESS)
2535}
2536
2537fn handle_gameitems_list(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
2538 let path = sub_matches
2539 .get_one::<String>("VPXPATH")
2540 .map(|s| s.as_str())
2541 .unwrap_or_default();
2542 let expanded_path = path_exists(path)?;
2543 let mut vpx_file = vpx::open(&expanded_path)?;
2544 let gameitems = vpx_file.read_gameitems()?;
2545
2546 let rows: Vec<Vec<String>> = gameitems
2547 .iter()
2548 .map(|item| {
2549 let visible = match item.is_visible() {
2550 Some(true) => "Y",
2551 Some(false) => "N",
2552 None => "-",
2553 };
2554 let locked = match item.is_locked() {
2555 Some(true) => "Y",
2556 Some(false) => "N",
2557 None => "-",
2558 };
2559 let layer = match item.editor_layer_name() {
2562 Some(name) if !name.is_empty() => name.clone(),
2563 _ => item
2564 .editor_layer()
2565 .map(|n| n.to_string())
2566 .unwrap_or_default(),
2567 };
2568 vec![
2569 item.name().to_string(),
2570 item.type_name(),
2571 visible.to_string(),
2572 locked.to_string(),
2573 layer,
2574 item.part_group_name().unwrap_or("").to_string(),
2575 item.physics_material().unwrap_or("").to_string(),
2576 item.images().join("--"),
2577 item.materials().join("--"),
2578 ]
2579 })
2580 .collect();
2581
2582 let headers = [
2583 "NAME",
2584 "TYPE",
2585 "VISIBLE",
2586 "LOCKED",
2587 "LAYER",
2588 "PART_GROUP",
2589 "PHYSICS_MATERIAL",
2590 "IMAGES",
2591 "MATERIALS",
2592 ];
2593 let aligns = [
2594 ColAlign::Left,
2595 ColAlign::Left,
2596 ColAlign::Left,
2597 ColAlign::Left,
2598 ColAlign::Left,
2599 ColAlign::Left,
2600 ColAlign::Left,
2601 ColAlign::Left,
2602 ColAlign::Left,
2603 ];
2604 print_aligned_table(&headers, &aligns, &rows)?;
2605 Ok(ExitCode::SUCCESS)
2606}
2607
2608fn extract_script_command(name: impl Into<Str>) -> Command {
2609 Command::new(name)
2610 .about("Extracts the script from a vpx file.")
2611 .long_about("Extracts the script from a vpx file by default into a vbs file next to it. Scripts placed next to the vpx file with the same name are considered sidecar scripts and will be picked up by Visual Pinball instead of the script inside the vpx file.")
2612 .arg(
2613 Arg::new("FORCE")
2614 .short('f')
2615 .long("force")
2616 .num_args(0)
2617 .default_value("false")
2618 .help("Will overwrite existing .vbs file if set."),
2619 )
2620 .arg(
2621 arg!(<VPXPATH> "The path to the vpx file")
2622 .required(true),
2623 )
2624 .arg(
2625 arg!([VBSPATH] "The optional path to the vbs file to write. Defaults to the vpx file path with the extension changed to .vbs. This option is mutually exclusive with DIRECTORY.")
2626 .required(false),
2627 )
2628 .arg(
2629 Arg::new("OUTPUT_DIRECTORY")
2630 .long("output-dir")
2631 .num_args(1)
2632 .required(false)
2633 .help("The directory to extract the vbs file to. Only if no VBSPATH is provided"),
2634 )
2635}
2636
2637fn open_or_fail(vbs_path: &Path, config: Option<&ResolvedConfig>) -> io::Result<ExitCode> {
2638 match open_editor(vbs_path, config) {
2639 Ok(_) => Ok(ExitCode::SUCCESS),
2640 Err(err) => {
2641 let msg = format!("Unable to open {}", vbs_path.to_string_lossy());
2642 fail_with_error(msg, err)
2643 }
2644 }
2645}
2646
2647fn fail_with_error(message: impl Display, err: impl Error) -> io::Result<ExitCode> {
2648 let warning = format!("{message}: {err}");
2649 fail(warning)
2650}
2651
2652fn fail<M: AsRef<str>>(message: M) -> io::Result<ExitCode> {
2653 let error = "error:".red();
2654 crate::eprintln!("{} {}", error, message.as_ref())?;
2655 Ok(ExitCode::FAILURE)
2656}
2657
2658fn new(vpx_file_path: &str) -> io::Result<()> {
2659 vpx::new_minimal_vpx(vpx_file_path)
2661}
2662
2663fn handle_extractvbs(sub_matches: &ArgMatches) -> io::Result<ExitCode> {
2664 let force = sub_matches.get_flag("FORCE");
2665 let vpx_path = sub_matches.get_one::<String>("VPXPATH").map(PathBuf::from);
2666 let vbs_path = sub_matches.get_one::<String>("VBSPATH").map(PathBuf::from);
2667 let directory = sub_matches
2668 .get_one::<String>("OUTPUT_DIRECTORY")
2669 .map(PathBuf::from);
2670 let expanded_vpx_path = path_exists(vpx_path.expect("should be checked by clap"))?;
2671 if !expanded_vpx_path.is_file() {
2672 return fail(format!(
2673 "VPXPATH not a file: {}",
2674 expanded_vpx_path.display()
2675 ));
2676 }
2677 if vbs_path.is_some() && directory.is_some() {
2678 return fail("Conflicting VBSPATH and DIRECTORY options, only one can be used");
2679 }
2680
2681 let vbs_path_opt = vbs_path.or_else(|| {
2682 directory.map(|dir| {
2683 dir.join(expanded_vpx_path.file_name().unwrap_or_default())
2684 .with_extension("vbs")
2685 })
2686 });
2687
2688 match extractvbs(&expanded_vpx_path, vbs_path_opt, force) {
2689 Ok(ExtractResult::Existed(vbs_path)) => {
2690 let warning = format!("EXISTED {}", vbs_path.display()).truecolor(255, 125, 0);
2691 crate::println!("{}", warning)?;
2692 }
2693 Ok(ExtractResult::Extracted(vbs_path)) => {
2694 crate::println!("CREATED {}", vbs_path.display())?;
2695 }
2696 Err(e) => {
2697 let warning = format!("Error extracting vbs: {e}").red();
2698 crate::eprintln!("{}", warning)?;
2699 }
2700 }
2701
2702 Ok(ExitCode::SUCCESS)
2703}
2704
2705fn extract_directb2s(expanded_path: &PathBuf, output_dir: Option<&Path>) -> io::Result<()> {
2706 let file = File::open(expanded_path)?;
2707 let reader = BufReader::new(file);
2708 match read(reader) {
2709 Ok(b2s) => {
2710 crate::println!("DirectB2S file version {}", b2s.version)?;
2711 let default_dir = expanded_path.with_extension("directb2s.extracted");
2712 let root_dir_path = output_dir.unwrap_or(default_dir.as_path());
2713
2714 let mut root_dir = std::fs::DirBuilder::new();
2715 root_dir.recursive(true);
2716 root_dir.create(root_dir_path)?;
2717
2718 crate::println!("Writing to {}", root_dir_path.display())?;
2719 wite_images(b2s, root_dir_path);
2720 }
2721 Err(msg) => {
2722 crate::println!("Failed to load {}: {}", expanded_path.display(), msg)?;
2723 exit(1);
2724 }
2725 }
2726 Ok(())
2727}
2728
2729fn wite_images(b2s: directb2s::DirectB2SData, root_dir_path: &Path) {
2730 if let Some(backglass_off_image) = b2s.images.backglass_off_image {
2731 write_base64_to_file(
2732 root_dir_path,
2733 None,
2734 "backglassimage.img".to_string(),
2735 &backglass_off_image.value,
2736 );
2737 }
2738 if let Some(backglass_on_image) = b2s.images.backglass_on_image {
2739 write_base64_to_file(
2740 root_dir_path,
2741 Some(backglass_on_image.file_name),
2742 "backglassimage.img".to_string(),
2743 &backglass_on_image.value,
2744 );
2745 }
2746 if let Some(backglass_image) = b2s.images.backglass_image {
2747 write_base64_to_file(
2748 root_dir_path,
2749 Some(backglass_image.file_name),
2750 "backglassimage.img".to_string(),
2751 &backglass_image.value,
2752 );
2753 }
2754
2755 if let Some(dmd_image) = b2s.images.dmd_image {
2756 write_base64_to_file(
2757 root_dir_path,
2758 Some(dmd_image.file_name),
2759 "dmdimage.img".to_string(),
2760 &dmd_image.value,
2761 );
2762 }
2763 if let Some(illumination_image) = b2s.images.illumination_image {
2764 write_base64_to_file(
2765 root_dir_path,
2766 None,
2767 "dmdimage.img".to_string(),
2768 &illumination_image.value,
2769 );
2770 }
2771
2772 let thumbnail_image = b2s.images.thumbnail_image;
2773 write_base64_to_file(
2774 root_dir_path,
2775 None,
2776 "thumbnailimage.png".to_string(),
2777 &thumbnail_image.value,
2778 );
2779
2780 for bulb in b2s.illumination.bulb.unwrap_or_default() {
2781 write_base64_to_file(
2782 root_dir_path,
2783 None,
2784 format!("{}.png", bulb.name).to_string(),
2785 &bulb.image,
2786 );
2787 if let Some(off_image) = bulb.off_image {
2788 write_base64_to_file(
2789 root_dir_path,
2790 None,
2791 format!("{}_off.png", bulb.name).to_string(),
2792 &off_image,
2793 );
2794 }
2795 }
2796
2797 if let Some(reel) = b2s.reels {
2798 for reels_image in reel.images.image.iter().flatten() {
2799 write_base64_to_file(
2800 root_dir_path,
2801 None,
2802 format!("{}.png", reels_image.name).to_string(),
2803 &reels_image.image,
2804 );
2805 }
2806 for illuminated_set in reel.illuminated_images.set.iter().flatten() {
2807 for reels_image in &illuminated_set.illuminated_image {
2808 write_base64_to_file(
2809 root_dir_path,
2810 None,
2811 format!("{}.png", reels_image.name).to_string(),
2812 &reels_image.image,
2813 );
2814 }
2815 }
2816 }
2817}
2818
2819fn write_base64_to_file(
2820 root_dir_path: &Path,
2821 original_file_path: Option<String>,
2822 default: String,
2823 base64data_with_cr_lf: &str,
2824) {
2825 let file_name: String =
2827 os_independent_file_name(original_file_path.unwrap_or(default.clone())).unwrap_or(default);
2828
2829 let file_path = root_dir_path.join(file_name);
2830
2831 let mut file = File::create(file_path).unwrap();
2832 let base64data = strip_cr_lf(base64data_with_cr_lf);
2833
2834 let decoded_data = base64::engine::general_purpose::STANDARD
2835 .decode(base64data)
2836 .unwrap();
2837 file.write_all(&decoded_data).unwrap();
2838}
2839
2840pub(crate) fn info_gather(vpx_file_path: &PathBuf) -> io::Result<String> {
2841 let mut vpx_file = vpx::open(vpx_file_path)?;
2842 let version = vpx_file.read_version()?;
2843 let table_info = vpx_file.read_tableinfo()?;
2846
2847 let mut buffer = String::new();
2848
2849 buffer.push_str(&format!("{:>18} {}\n", "VPX Version:".green(), version));
2850 buffer.push_str(&format!(
2851 "{:>18} {}\n",
2852 "Table Name:".green(),
2853 table_info.table_name.unwrap_or("[not set]".to_string())
2854 ));
2855 buffer.push_str(&format!(
2856 "{:>18} {}\n",
2857 "Version:".green(),
2858 table_info.table_version.unwrap_or("[not set]".to_string())
2859 ));
2860 buffer.push_str(&format!(
2861 "{:>18} {}{}{}\n",
2862 "Author:".green(),
2863 Some(table_info.author_name)
2864 .map(|s| s.unwrap_or("[not set]".to_string()))
2865 .filter(|s| !s.is_empty())
2866 .map(|s| format!("{s} "))
2867 .unwrap_or_default(),
2868 Some(table_info.author_email)
2869 .map(|s| s.unwrap_or("[not set]".to_string()))
2870 .filter(|s| !s.is_empty())
2871 .map(|s| format!("{s} "))
2872 .unwrap_or_default(),
2873 Some(table_info.author_website)
2874 .map(|s| s.unwrap_or("[not set]".to_string()))
2875 .filter(|s| !s.is_empty())
2876 .map(|s| format!("{s} "))
2877 .unwrap_or_default(),
2878 ));
2879 buffer.push_str(&format!(
2880 "{:>18} {}\n",
2881 "Save revision:".green(),
2882 table_info.table_save_rev.unwrap_or("[not set]".to_string())
2883 ));
2884 buffer.push_str(&format!(
2885 "{:>18} {}\n",
2886 "Save date:".green(),
2887 table_info
2888 .table_save_date
2889 .unwrap_or("[not set]".to_string())
2890 ));
2891 buffer.push_str(&format!(
2892 "{:>18} {}\n",
2893 "Release Date:".green(),
2894 table_info.release_date.unwrap_or("[not set]".to_string())
2895 ));
2896 buffer.push_str(&format!(
2897 "{:>18} {}\n",
2898 "Description:".green(),
2899 table_info
2900 .table_description
2901 .unwrap_or("[not set]".to_string())
2902 ));
2903 buffer.push_str(&format!(
2904 "{:>18} {}\n",
2905 "Blurb:".green(),
2906 table_info.table_blurb.unwrap_or("[not set]".to_string())
2907 ));
2908 buffer.push_str(&format!(
2909 "{:>18} {}\n",
2910 "Rules:".green(),
2911 table_info.table_rules.unwrap_or("[not set]".to_string())
2912 ));
2913
2914 for (prop, value) in &table_info.properties {
2915 buffer.push_str(&format!("{:>18}: {}\n", prop.green(), value));
2916 }
2917
2918 Ok(buffer)
2919}
2920
2921fn info_extract(vpx_file_path: &Path) -> io::Result<ExitCode> {
2922 let info_file_path = vpx_file_path.with_extension("info.json");
2923 if info_file_path.exists() {
2924 let confirmed = confirm(
2925 format!("File \"{}\" already exists", info_file_path.display()),
2926 "Do you want to overwrite the existing file?".to_string(),
2927 )?;
2928 if !confirmed {
2929 crate::println!("Aborted")?;
2930 return Ok(ExitCode::FAILURE);
2931 }
2932 }
2933 write_info_json(vpx_file_path, &info_file_path)?;
2934 crate::println!("Extracted table info to {}", info_file_path.display())?;
2935 Ok(ExitCode::SUCCESS)
2936}
2937
2938fn write_info_json(vpx_file_path: &Path, info_file_path: &Path) -> io::Result<()> {
2939 let mut vpx_file = vpx::open(vpx_file_path)?;
2940 let table_info = vpx_file.read_tableinfo()?;
2941 let custom_info_tags = vpx_file.read_custominfotags()?;
2942 let table_info_json = info_to_json(&table_info, &custom_info_tags);
2943 let info_file = File::create(info_file_path)?;
2944 serde_json::to_writer_pretty(info_file, &table_info_json)?;
2945 Ok(())
2946}
2947
2948pub(crate) fn info_edit(
2949 vpx_file_path: &Path,
2950 config: Option<&ResolvedConfig>,
2951) -> io::Result<PathBuf> {
2952 let info_file_path = vpx_file_path.with_extension("info.json");
2953 if !info_file_path.exists() {
2954 write_info_json(vpx_file_path, &info_file_path)?;
2955 }
2956 open_editor(&info_file_path, config)?;
2957 Ok(info_file_path)
2958}
2959
2960pub(crate) fn open_editor(file_to_edit: &Path, config: Option<&ResolvedConfig>) -> io::Result<()> {
2961 match config.iter().flat_map(|c| c.editor.clone()).next() {
2962 Some(editor) => open_configured_editor(file_to_edit, &editor),
2963 None => edit::edit_file(file_to_edit),
2964 }
2965}
2966
2967fn open_configured_editor(file_to_edit: &Path, editor: &String) -> io::Result<()> {
2968 let mut command = std::process::Command::new(editor);
2969 command.arg(file_to_edit);
2970 command.stdout(std::process::Stdio::inherit());
2971 command.stderr(std::process::Stdio::inherit());
2972 match command.status() {
2973 Ok(status) => {
2974 if status.success() {
2975 Ok(())
2976 } else {
2977 let warning = format!("Failed to open editor {editor}: {status}");
2978 Err(io::Error::other(warning))
2979 }
2980 }
2981 Err(e) => {
2982 let warning = format!("Failed to open editor {}: {}", editor, e);
2983 Err(io::Error::other(warning))
2984 }
2985 }
2986}
2987
2988fn info_import(_vpx_file_path: &Path) -> io::Result<ExitCode> {
2989 fail("Not yet implemented")
3013}
3014
3015pub fn ls(vpx_file_path: &Path) -> io::Result<()> {
3016 expanded::extract_directory_list(vpx_file_path)
3017 .iter()
3018 .try_for_each(|file_path| crate::println!("{}", file_path))
3019}
3020
3021pub fn confirm(msg: String, yes_no_question: String) -> io::Result<bool> {
3022 let warning = msg.truecolor(255, 125, 0);
3025 crate::println!("{}", warning)?;
3026 crate::println!("{} (y/n)", yes_no_question)?;
3027 let mut input = String::new();
3028 io::stdin().read_line(&mut input)?;
3029 Ok(input.trim() == "y")
3030}
3031
3032pub fn extract(vpx_file_path: &Path, yes: bool, output_dir: Option<&Path>) -> io::Result<ExitCode> {
3033 let default_dir = vpx_file_path.with_extension("");
3034 let root_dir_path = output_dir.unwrap_or(default_dir.as_path());
3035
3036 if root_dir_path.exists() && !yes {
3038 let confirmed = confirm(
3039 format!("Directory \"{}\" already exists", root_dir_path.display()),
3040 "Do you want to remove the existing directory?".to_string(),
3041 )?;
3042 if !confirmed {
3043 crate::println!("Aborted")?;
3044 return Ok(ExitCode::FAILURE);
3045 }
3046 }
3047 if root_dir_path.exists() {
3048 std::fs::remove_dir_all(root_dir_path)?;
3049 }
3050 let mut root_dir = std::fs::DirBuilder::new();
3051 root_dir.recursive(true);
3052 root_dir.create(root_dir_path)?;
3053 let result = {
3054 let vpx = vpx::read(vpx_file_path)?;
3055 let options = ExpandOptions::default();
3056 expanded::write(&vpx, &root_dir_path, &options)
3057 };
3058 match result {
3059 Ok(_) => {
3060 crate::println!("Successfully extracted to \"{}\"", root_dir_path.display())?;
3061 Ok(ExitCode::SUCCESS)
3062 }
3063 Err(e) => fail(format!("Failed to extract: {e}")),
3064 }
3065}
3066
3067pub fn info_diff(vpx_file_path: &Path, config: Option<&ResolvedConfig>) -> io::Result<String> {
3068 let expanded_vpx_path = path_exists(vpx_file_path)?;
3069 let info_file_path = expanded_vpx_path.with_extension("info.json");
3070 if info_file_path.exists() {
3071 let original_info_path =
3072 RemoveOnDrop::new(vpx_file_path.with_extension("info.original.tmp"));
3073 write_info_json(&expanded_vpx_path, original_info_path.path())?;
3074 let diff_color = if colored::control::SHOULD_COLORIZE.should_colorize() {
3075 DiffColor::Always
3076 } else {
3077 DiffColor::Never
3078 };
3079 let output = run_diff(
3080 original_info_path.path(),
3081 &info_file_path,
3082 diff_color,
3083 config,
3084 )?;
3085 Ok(String::from_utf8_lossy(&output).to_string())
3086 } else {
3087 let msg = format!("No sidecar info file found: {}", info_file_path.display());
3088 Err(io::Error::new(io::ErrorKind::NotFound, msg))
3089 }
3090}
3091
3092pub fn script_diff(vpx_file_path: &Path, config: Option<&ResolvedConfig>) -> io::Result<String> {
3093 let vbs_path = vpx_file_path.with_extension("vbs");
3095 if vbs_path.exists() {
3096 match vpx::open(vpx_file_path) {
3097 Ok(mut vpx_file) => {
3098 let gamedata = vpx_file.read_gamedata()?;
3099 let script = gamedata.code;
3100 let original_vbs_path =
3101 RemoveOnDrop::new(vpx_file_path.with_extension("vbs.original.tmp"));
3102 std::fs::write(original_vbs_path.path(), script.string)?;
3103 let diff_color = if colored::control::SHOULD_COLORIZE.should_colorize() {
3104 DiffColor::Always
3105 } else {
3106 DiffColor::Never
3107 };
3108 let output = run_diff(original_vbs_path.path(), &vbs_path, diff_color, config)?;
3109 Ok(String::from_utf8_lossy(&output).to_string())
3110 }
3111 Err(e) => {
3112 let msg = format!("Not a valid vpx file {}: {}", vpx_file_path.display(), e);
3113 Err(io::Error::new(io::ErrorKind::InvalidData, msg))
3114 }
3115 }
3116 } else {
3117 let msg = format!("No sidecar vbs file found: {}", vbs_path.display());
3119 Err(io::Error::new(io::ErrorKind::NotFound, msg))
3120 }
3121}
3122
3123pub enum DiffColor {
3124 Always,
3125 Never,
3126}
3127
3128impl DiffColor {
3129 fn to_diff_arg(&self) -> String {
3131 match self {
3132 DiffColor::Always => String::from("always"),
3133 DiffColor::Never => String::from("never"),
3134 }
3135 }
3136}
3137
3138pub fn run_diff(
3139 original_vbs_path: &Path,
3140 vbs_path: &Path,
3141 color: DiffColor,
3142 config: Option<&ResolvedConfig>,
3143) -> Result<Vec<u8>, io::Error> {
3144 let original_vbs_filename = original_vbs_path
3145 .file_name()
3146 .unwrap_or(original_vbs_path.as_os_str());
3147 let original_vbs_file_name_no_tmp = original_vbs_filename.to_string_lossy().replace(".tmp", "");
3148 let vbs_filename = vbs_path.file_name().unwrap_or(vbs_path.as_os_str());
3149 let diff = config
3150 .and_then(|resolved| resolved.diff.as_deref())
3151 .unwrap_or("diff");
3152 let mut command = std::process::Command::new(diff);
3153 match vbs_path.parent() {
3154 Some(parent) if !parent.as_os_str().is_empty() => {
3155 command.current_dir(parent);
3156 }
3157 _ => {}
3158 }
3159 command
3160 .arg("-u")
3161 .arg("-w")
3162 .arg(format!("--color={}", color.to_diff_arg()))
3163 .arg(format!("--label={original_vbs_file_name_no_tmp}"))
3164 .arg(original_vbs_filename)
3165 .arg(format!("--label={}", vbs_filename.to_string_lossy()))
3166 .arg(vbs_filename);
3167 info!("Running command: {:?}", command);
3168 let result = command.output().map(|o| o.stdout);
3169 result.map_err(|e| {
3170 let msg = format!("Failed to run diff '{diff}'. Is it installed on your system? {e}");
3171 io::Error::other(msg)
3172 })
3173}
3174
3175fn show_dip_switches(nvram: &PathBuf) -> io::Result<String> {
3176 let mut nvram_file = OpenOptions::new().read(true).open(nvram)?;
3177 let switches = get_all_dip_switches(&mut nvram_file)?;
3178
3179 let mut lines = Vec::new();
3180 for s in switches {
3181 lines.push(format!(
3182 "DIP #{}: {}",
3183 s.nr,
3184 if s.on { "ON" } else { "OFF" }
3185 ));
3186 }
3187
3188 let summary = lines.join("\n");
3189 Ok(summary)
3190}
3191
3192fn run_lock(sub_matches: &ArgMatches, action: LockAction) -> io::Result<ExitCode> {
3193 let path = sub_matches
3194 .get_one::<String>("VPXPATH")
3195 .expect("VPXPATH is required");
3196 let expanded = path_exists(path)?;
3197 apply_lock_action(&expanded, &action)?;
3198 Ok(ExitCode::SUCCESS)
3199}
3200
3201fn apply_lock_action(path: &Path, action: &LockAction) -> io::Result<()> {
3202 match action {
3203 LockAction::Status => {
3204 let mut vpx = vpx::open(path)?;
3205 let state = if vpx.is_locked()? {
3206 "locked"
3207 } else {
3208 "unlocked"
3209 };
3210 crate::println!("{}: {}", path.display(), state)?;
3211 }
3212 LockAction::Lock => {
3213 let mut vpx = vpx::open_rw(path)?;
3214 if vpx.lock()? {
3215 crate::println!("Locked {}", path.display())?;
3216 } else {
3217 crate::println!("Already locked: {}", path.display())?;
3218 }
3219 }
3220 LockAction::Unlock => {
3221 let mut vpx = vpx::open_rw(path)?;
3222 if vpx.unlock()? {
3223 crate::println!("Unlocked {}", path.display())?;
3224 } else {
3225 crate::println!("Already unlocked: {}", path.display())?;
3226 }
3227 }
3228 }
3229 Ok(())
3230}