Skip to main content

vpxtool/
cli.rs

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
35// see https://github.com/fusion-engineering/rust-git-version/issues/21
36const 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    // only if the RUST_LOG env var is not set
176    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            // TODO this should diff more than only the script
252            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                // Use the modern path for this session regardless of the
284                // rewrite outcome, so the rest of the run reads the right ini.
285                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                            // if the config is invalid, we still want to allow editing
697                            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!(), // If all subcommands are defined above, anything else is unreachable!()
805    }
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    // Single table.
924    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    // Batch over all tables in the configured tables folder.
954    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    // to allow for non-static strings in clap
1019    // I had to enable the "string" module
1020    // is this considered a bad idea?
1021    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
1863/// Resolve `path` to an nvram file. Accepts:
1864/// * a `.nv` file directly,
1865/// * a rom `.zip` (looks for `../nvram/<stem>.nv` next to it), or
1866/// * a `.vpx` (reads the rom name from the script and searches the configured
1867///   / global pinmame folders).
1868///
1869/// Returns a CLI-friendly error path on the failure cases so callers can just
1870/// `?` into the rest of their handler.
1871enum 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    // Resolve the input into a flat list of sections, trying PinMAME first
2002    // and falling back to VPReg.ini for .vpx tables that are not PinMAME.
2003    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                // For a rom-less .vpx we probed VPReg, GLF, and EM .txt
2020                // before giving up - the original "not PinMAME-based" wording
2021                // would suggest we never tried. Surface a holistic message.
2022                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(&sections, format)
2035}
2036
2037/// If `expanded_path` is a `.vpx` that PinMAME resolution couldn't handle,
2038/// probe the non-PinMAME score storage backends in order: VPReg first
2039/// (by far the most common rom-less storage; one shared `user/VPReg.ini`
2040/// keyed by `[<cGameName>]`), then GLF (a `<cGameName>_glf.ini` sibling
2041/// from the vpx-glf framework, far less common in the wild).
2042///
2043/// Returns `Ok(None)` when no backend matched (non-vpx input, non-fallthrough
2044/// error, no `cGameName` in the script, or no scores anywhere) so the caller
2045/// falls back to the original PinMAME error message. Returns `Err` only when
2046/// a backend file *exists* but is malformed - that's a real failure, not a
2047/// "this backend doesn't apply" signal.
2048fn 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    // VPReg: extract every distinct section key the script writes under
2067    // (the modern pattern uses `cGameName`, but `TableName`, `MyTable`,
2068    // `cGameSaveName`, and hardcoded literals are all common in the wild).
2069    // Probe each VPReg.ini candidate against every key the script names
2070    // and return the first hit. `user/VPReg.ini` is checked before the
2071    // sibling because standalone vpinball writes there by default.
2072    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    // GLF still keys off cGameName specifically because the vpx-glf
2097    // framework hardcodes `<CGameName>_glf.ini` as the filename.
2098    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                // GLF file present but no usable scores - return None so the
2105                // caller surfaces the original PinMAME error.
2106                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    // EM-style `.txt` (Black's Highscore routines): per-table file with
2119    // non-canonical name and a variable header. Glob `user/*.txt` (the
2120    // standard standalone-vpinball location) and then `*.txt` in the table
2121    // folder, sniffing each for the 5-scores-then-5-initials block. First
2122    // parse-success wins.
2123    if let Some(sections) = try_emhs_glob(vpx_parent)? {
2124        return Ok(Some(sections));
2125    }
2126
2127    Ok(None)
2128}
2129
2130/// Probe candidate EM-style score `.txt` files in `user/` then the table
2131/// folder root. Returns the first file whose content yields a valid score
2132/// block; `Ok(None)` when none match (or directories don't exist).
2133fn 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        // Sort so the probe order is reproducible across runs (read_dir's
2139        // iteration order is OS- and inode-dependent otherwise).
2140        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
2167/// Render a resolved `Vec<Section>` in the requested format. Common rendering
2168/// path shared by every backend (PinMAME nvram, GLF, VPReg.ini).
2169fn render_sections(sections: &[crate::scores::Section], format: &str) -> io::Result<ExitCode> {
2170    match format {
2171        "tsv" => {
2172            // Header + raw rows, tab-separated. Scores stay as raw integers
2173            // so scripts can `awk -F$'\t' '$3>=1000000'` directly; the
2174            // trailing UNITS column lets scripts that care format time-like
2175            // scores themselves. Locale-independent by design.
2176            crate::println!("{}", crate::scores::HEADERS.join("\t"))?;
2177            for section in sections {
2178                for row in &section.rows {
2179                    crate::println!("{}", row.join("\t"))?;
2180                }
2181            }
2182        }
2183        "pinemhi" => {
2184            // Section-based layout matching PINemHi's output shape: separate
2185            // GRAND CHAMPION block when present, one HIGH SCORES block for
2186            // ranked entries, one section per mode champion.
2187            // Branch on the locale type rather than using a `&dyn Format` -
2188            // num-format's ToFormattedString requires Sized. Windows lacks
2189            // SystemLocale (see Cargo.toml note) so it always uses Locale::en.
2190            #[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            // Human table view: drop the trailing UNITS column after using
2202            // it to format the SCORE column (e.g. seconds -> mm:ss).
2203            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/// Read the user's `LC_ALL` / `LANG` / `LC_NUMERIC` to pick a thousands
2231/// separator, but **fall back to `Locale::en` (comma) when the system locale
2232/// has no separator at all** - the POSIX `C` / `POSIX` locales specify
2233/// `thousands_sep=""`, which would render `52000000` instead of `52,000,000`.
2234/// PINemHi makes the same readability-over-strict-POSIX call, so we match.
2235///
2236/// Windows-only note: `num-format`'s `with-system-locale` Windows backend
2237/// fails to build (https://github.com/bcmyers/num-format/issues/43), so
2238/// this function is not compiled there; the call sites fall back to
2239/// `Locale::en` directly.
2240#[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/// Right- vs left-aligned column. The last column is printed without trailing
2250/// padding regardless, so paths with spaces don't break awk-style splitting
2251/// on the preceding fields.
2252#[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            // For WAV (PCM) we can derive the playback duration from the raw
2377            // sample bytes; for non-WAV containers the data is compressed and
2378            // we'd have to parse the container, so leave it blank.
2379            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,  // NAME
2408        ColAlign::Left,  // FORMAT
2409        ColAlign::Left,  // OUTPUT
2410        ColAlign::Right, // PAN
2411        ColAlign::Right, // FADE
2412        ColAlign::Right, // VOL
2413        ColAlign::Right, // FREQ
2414        ColAlign::Right, // CHAN
2415        ColAlign::Right, // LENGTH
2416        ColAlign::Right, // SIZE
2417        ColAlign::Left,  // PATH
2418    ];
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    // Build a uniform Vec<Vec<String>> from whichever material format is
2473    // present. 10.8+ uses the MATR storage (`gamedata.materials`); older
2474    // files use the legacy MATE storage (`gamedata.materials_old`).
2475    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            // Prefer the editor layer name; fall back to the numeric layer if
2560            // unnamed, empty if neither is set.
2561            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    // TODO check if file exists and prompt to overwrite / add option to force
2660    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    // TODO bring in the other default here
2826    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    // GameData also has a name field that we might want to display here
2844    // where is this shown in the UI?
2845    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    // let info_file_path = vpx_file_path.with_extension("info.json");
2990    // if !info_file_path.exists() {
2991    //     let warning = format!("File \"{}\" does not exist", info_file_path.display());
2992    //     return Err(io::Error::new(io::ErrorKind::NotFound, warning));
2993    // }
2994    // let mut info_file = File::open(&info_file_path)?;
2995    // let json = serde_json::from_reader(&mut info_file).map_err(|e| {
2996    //     io::Error::new(
2997    //         io::ErrorKind::Other,
2998    //         format!(
2999    //             "Failed to parse/read json {}: {}",
3000    //             info_file_path.display(),
3001    //             e
3002    //         ),
3003    //     )
3004    // })?;
3005
3006    // let (table_info, custom_info_tags) = json_to_info(json, None)?;
3007    // let mut vpx_file = vpx::open(vpx_file_path)?;
3008    // vpx_file.write_custominfotags(&custom_info_tags)?;
3009    // vpx_file.write_tableinfo(&table_info)?;
3010    // println!("Imported table info from {}", info_file_path.display())?;
3011    // Ok(ExitCode::SUCCESS)
3012    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    // TODO do we need to check for terminal here?
3023    //   let use_color = stdout().is_terminal();
3024    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    // ask for confirmation if the directory exists
3037    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    // set extension for PathBuf
3094    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        // wrap the error
3118        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    // to color arg
3130    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}