├── docs └── frontend.png ├── testdata ├── completely_blank_table_10_7_4.vpx ├── test.legacy.pov └── test.pov ├── .gitattributes ├── .editorconfig ├── src ├── main.rs ├── assets │ ├── standup_target_class.vbs │ └── drop_target_class.vbs ├── fixprint.rs ├── lib.rs ├── colorful_theme_patched.rs ├── vpinball_config.rs ├── patcher.rs ├── backglass.rs ├── config.rs ├── frontend.rs └── indexer.rs ├── .cargo └── config.toml ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── rust.yml │ ├── clippy.yml │ ├── release-plz.yml │ └── release.yml ├── Cargo.toml ├── README.md ├── CHANGELOG.md └── LICENSE /docs/frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francisdb/vpxtool/HEAD/docs/frontend.png -------------------------------------------------------------------------------- /testdata/completely_blank_table_10_7_4.vpx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francisdb/vpxtool/HEAD/testdata/completely_blank_table_10_7_4.vpx -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | *.vbs text eol=crlf 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [src/**.rs] 8 | indent_style = space 9 | indent_size = 4 -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitCode; 2 | use vpxtool::cli::run; 3 | use vpxtool::fixprint; 4 | 5 | fn main() -> ExitCode { 6 | fixprint::safe_main(run) 7 | } 8 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/rust-lang/rust/issues/28924 2 | # Make sure the correct linker is used for cross-compiling 3 | [target.aarch64-unknown-linux-musl] 4 | linker = "rust-lld" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | # Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # local vpxtool config file 17 | vpxtool.cfg 18 | 19 | # Visual Studio Code workspace file 20 | .vscode/ 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | # Check for updates to GitHub Actions every week 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | build: 17 | strategy: 18 | matrix: 19 | platform: 20 | - os: ubuntu-latest 21 | - os: windows-latest 22 | - os: macos-latest 23 | 24 | runs-on: ${{ matrix.platform.os }} 25 | 26 | steps: 27 | - uses: actions/checkout@v6 28 | - uses: dtolnay/rust-toolchain@stable 29 | - uses: Swatinem/rust-cache@v2.8.2 30 | - name: Build 31 | run: cargo build --verbose 32 | - name: Run tests 33 | run: cargo test --verbose 34 | -------------------------------------------------------------------------------- /src/assets/standup_target_class.vbs: -------------------------------------------------------------------------------- 1 | Class StandupTarget 2 | Private m_primary, m_prim, m_sw, m_animate 3 | 4 | Public Property Get Primary(): Set Primary = m_primary: End Property 5 | Public Property Let Primary(input): Set m_primary = input: End Property 6 | 7 | Public Property Get Prim(): Set Prim = m_prim: End Property 8 | Public Property Let Prim(input): Set m_prim = input: End Property 9 | 10 | Public Property Get Sw(): Sw = m_sw: End Property 11 | Public Property Let Sw(input): m_sw = input: End Property 12 | 13 | Public Property Get Animate(): Animate = m_animate: End Property 14 | Public Property Let Animate(input): m_animate = input: End Property 15 | 16 | Public default Function init(primary, prim, sw, animate) 17 | Set m_primary = primary 18 | Set m_prim = prim 19 | m_sw = sw 20 | m_animate = animate 21 | 22 | Set Init = Me 23 | End Function 24 | End Class 25 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yml: -------------------------------------------------------------------------------- 1 | name: Clippy check 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: [ "main" ] 8 | pull_request: 9 | branches: [ "main" ] 10 | 11 | # Make sure CI fails on all warnings, including Clippy lints 12 | env: 13 | RUSTFLAGS: "-Dwarnings" 14 | 15 | jobs: 16 | clippy_check: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: Install minimal stable with clippy and rustfmt 21 | uses: dtolnay/rust-toolchain@stable 22 | with: 23 | toolchain: stable 24 | components: rustfmt, clippy 25 | - uses: Swatinem/rust-cache@v2.8.2 26 | - name: Run clippy 27 | run: cargo clippy --color always --all-targets --all-features 28 | - name: Run fmt check 29 | run: cargo fmt --all --check -- --color=always 30 | - name: Run audit 31 | run: cargo install cargo-audit --force && cargo audit 32 | # to update the dependencies in the lock file, run `cargo update` 33 | -------------------------------------------------------------------------------- /testdata/test.legacy.pov: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 40.599979 5 | 46.000000 6 | 0.000000 7 | 0.000000 8 | 1.000000 9 | 0.938001 10 | 1.000000 11 | 0.000000 12 | -26.800074 13 | -300.000000 14 | 15 | 16 | 0.000000 17 | 27.000000 18 | 76.000000 19 | 270.000000 20 | 1.000000 21 | 1.160000 22 | 1.000000 23 | -10.000000 24 | 0.000000 25 | -200.000000 26 | 27 | 28 | 54.000000 29 | 32.000000 30 | 0.000000 31 | 0.000000 32 | 1.000000 33 | 1.000000 34 | 1.000000 35 | 0.000000 36 | 0.000000 37 | -250.000000 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/assets/drop_target_class.vbs: -------------------------------------------------------------------------------- 1 | Class DropTarget 2 | Private m_primary, m_secondary, m_prim, m_sw, m_animate, m_isDropped 3 | 4 | Public Property Get Primary(): Set Primary = m_primary: End Property 5 | Public Property Let Primary(input): Set m_primary = input: End Property 6 | 7 | Public Property Get Secondary(): Set Secondary = m_secondary: End Property 8 | Public Property Let Secondary(input): Set m_secondary = input: End Property 9 | 10 | Public Property Get Prim(): Set Prim = m_prim: End Property 11 | Public Property Let Prim(input): Set m_prim = input: End Property 12 | 13 | Public Property Get Sw(): Sw = m_sw: End Property 14 | Public Property Let Sw(input): m_sw = input: End Property 15 | 16 | Public Property Get Animate(): Animate = m_animate: End Property 17 | Public Property Let Animate(input): m_animate = input: End Property 18 | 19 | Public Property Get IsDropped(): IsDropped = m_isDropped: End Property 20 | Public Property Let IsDropped(input): m_isDropped = input: End Property 21 | 22 | Public default Function init(primary, secondary, prim, sw, animate, isDropped) 23 | Set m_primary = primary 24 | Set m_secondary = secondary 25 | Set m_prim = prim 26 | m_sw = sw 27 | m_animate = animate 28 | m_isDropped = isDropped 29 | 30 | Set Init = Me 31 | End Function 32 | End Class 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "vpxtool" 3 | edition = "2024" 4 | version = "0.24.8" 5 | license = "MIT" 6 | description = "Terminal based frontend and utilities for Visual Pinball" 7 | repository = "https://github.com/francisdb/vpxtool" 8 | readme = "README.md" 9 | documentation = "https://docs.rs/vpxtool" 10 | 11 | [lib] 12 | path = "src/lib.rs" 13 | 14 | [[bin]] 15 | path = "src/main.rs" 16 | name = "vpxtool" 17 | 18 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 19 | 20 | [dependencies] 21 | base64 = "0.22.1" 22 | clap = { version = "4.5.53", features = ["derive", "string"] } 23 | colored = "3.0.0" 24 | console = "0.16.1" 25 | dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } 26 | # for the theme override 27 | fuzzy-matcher = "0.3.7" 28 | git-version = "0.3.9" 29 | indicatif = "0.18.3" 30 | jojodiff = "0.1.2" 31 | serde_json = { version = "1.0.145", features = ["preserve_order"] } 32 | shellexpand = "3.1.1" 33 | wild = "2.2.1" 34 | 35 | is_executable = "1.0.5" 36 | regex = { version = "1.12.2", features = [] } 37 | vpin = { version = "0.19.0" } 38 | 39 | edit = "0.1.5" 40 | pinmame-nvram = "0.4.3" 41 | image = "0.25.9" 42 | 43 | #see https://github.com/chronotope/chrono/issues/602#issuecomment-1242149249 44 | chrono = { version = "0.4.42", default-features = false, features = ["clock"] } 45 | rust-ini = "0.21.3" 46 | dirs = "6.0.0" 47 | toml = "0.9.8" 48 | serde = { version = "1.0.228", features = ["derive"] } 49 | log = "0.4.29" 50 | env_logger = "0.11.5" 51 | figment = { version = "0.10", features = ["toml", "env"] } 52 | walkdir = "2.5.0" 53 | rayon = "1.11.0" 54 | 55 | [dev-dependencies] 56 | testdir = "0.9.3" 57 | pretty_assertions = "1.4.1" 58 | rand = "0.9.2" 59 | 60 | 61 | [profile.test] 62 | # level 0 is very slow for writing to compound files 63 | # see https://github.com/mdsteele/rust-cfb/issues/42 64 | opt-level = 1 65 | -------------------------------------------------------------------------------- /src/fixprint.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | use std::io; 3 | use std::io::Write; 4 | use std::process::ExitCode; 5 | 6 | // These macros are needed because the normal ones panic when there's a broken pipe. 7 | // This is especially problematic for CLI tools that are frequently piped into `head` or `grep -q` 8 | // From https://github.com/rust-lang/rust/issues/46016#issuecomment-1242039016 9 | #[macro_export] 10 | macro_rules! println { 11 | () => (print!("\n")); 12 | ($fmt:expr) => ({ 13 | writeln!(std::io::stdout(), $fmt) 14 | }); 15 | ($fmt:expr, $($arg:tt)*) => ({ 16 | writeln!(std::io::stdout(), $fmt, $($arg)*) 17 | }) 18 | } 19 | 20 | #[macro_export] 21 | macro_rules! print { 22 | () => (print!("\n")); 23 | ($fmt:expr) => ({ 24 | write!(std::io::stdout(), $fmt) 25 | }); 26 | ($fmt:expr, $($arg:tt)*) => ({ 27 | write!(std::io::stdout(), $fmt, $($arg)*) 28 | }) 29 | } 30 | 31 | #[macro_export] 32 | macro_rules! eprintln { 33 | () => (eprint!("\n")); 34 | ($fmt:expr) => ({ 35 | writeln!(&mut std::io::stderr(), $fmt) 36 | }); 37 | ($fmt:expr, $($arg:tt)*) => ({ 38 | writeln!(&mut std::io::stderr(), $fmt, $($arg)*) 39 | }) 40 | } 41 | 42 | #[macro_export] 43 | macro_rules! eprint { 44 | () => (eprint!("\n")); 45 | ($fmt:expr) => ({ 46 | write!(&mut std::io::stderr(), $fmt) 47 | }); 48 | ($fmt:expr, $($arg:tt)*) => ({ 49 | write!(&mut std::io::stderr(), $fmt, $($arg)*) 50 | }) 51 | } 52 | 53 | pub fn safe_main(main: fn() -> io::Result) -> ExitCode { 54 | // from https://github.com/rust-lang/rust/issues/46016#issuecomment-1242039016 55 | match main() { 56 | Err(err) if err.kind() == io::ErrorKind::BrokenPipe => { 57 | // Okay, this happens when the output is piped to a program like `head` 58 | ExitCode::SUCCESS 59 | } 60 | Err(err) => { 61 | let warning = format!("{err}").red(); 62 | eprintln!("{warning}").ok(); 63 | ExitCode::FAILURE 64 | } 65 | Ok(exit_code) => exit_code, //::SUCCESS, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | 3 | # GitHub Actions using the default GITHUB_TOKEN cannot trigger other workflow runs, i.e. cannot start new GitHub Actions jobs. 4 | # We followed the workaround below 5 | # https://release-plz.dev/docs/github/token#how-to-trigger-further-workflow-runs 6 | 7 | permissions: 8 | pull-requests: write 9 | contents: write 10 | 11 | on: 12 | push: 13 | branches: 14 | - main 15 | 16 | jobs: 17 | 18 | # Release unpublished packages. 19 | release-plz-release: 20 | name: Release-plz release 21 | runs-on: ubuntu-latest 22 | if: ${{ github.repository_owner == 'francisdb' && github.actor != 'dependabot[bot]' }} 23 | permissions: 24 | contents: write 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v6 28 | with: 29 | fetch-depth: 0 30 | token: ${{ secrets.VPXTOOL_RELEASE_PLZ_TOKEN }} 31 | - name: Install Rust toolchain 32 | uses: dtolnay/rust-toolchain@stable 33 | - name: Run release-plz 34 | uses: release-plz/action@v0.5 35 | with: 36 | command: release 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.VPXTOOL_RELEASE_PLZ_TOKEN }} 39 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 40 | 41 | # Create a PR with the new versions and changelog, preparing the next release. 42 | release-plz-pr: 43 | name: Release-plz PR 44 | runs-on: ubuntu-latest 45 | permissions: 46 | contents: write 47 | pull-requests: write 48 | concurrency: 49 | group: release-plz-${{ github.ref }} 50 | cancel-in-progress: false 51 | steps: 52 | - name: Checkout repository 53 | uses: actions/checkout@v6 54 | with: 55 | fetch-depth: 0 56 | - name: Install Rust toolchain 57 | uses: dtolnay/rust-toolchain@stable 58 | - name: Run release-plz 59 | uses: release-plz/action@v0.5 60 | with: 61 | command: release-pr 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 65 | -------------------------------------------------------------------------------- /testdata/test.pov: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0 5 | 0 6 | 46.399986 7 | 0 8 | 39.999962 9 | 0 10 | 1 11 | 0.98799998 12 | 1 13 | 0 14 | 46.000061 15 | -320 16 | 0 17 | 0 18 | 0 19 | 0 20 | 370.54193 21 | 0 22 | 0 23 | 138.95322 24 | 25 | 26 | 2 27 | 2 28 | 77 29 | 50.200008 30 | 25 31 | 270 32 | 1.0540019 33 | 1.2159998 34 | 1 35 | 0 36 | 186.54193 37 | 952.16772 38 | -1.4901161e-08 39 | -11.100006 40 | -5 41 | -275 42 | 365.54193 43 | 1 44 | -34 45 | 230.95322 46 | 47 | 48 | 0 49 | 0 50 | 45 51 | 0 52 | 52 53 | 0 54 | 1.2 55 | 1.1 56 | 1 57 | 0 58 | 30 59 | -50 60 | 0 61 | 0 62 | 0 63 | 0 64 | 370.54193 65 | 0 66 | 0 67 | 138.95322 68 | 69 | 70 | -1 71 | -1 72 | -1 73 | -1 74 | -1 75 | 0 76 | 5 77 | -1 78 | -1 79 | 0.2 80 | 1 81 | 63 82 | 19.999998 83 | 0 84 | 0 85 | 100 86 | 100 87 | 88 | 89 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - 'v*' 8 | permissions: 9 | contents: write 10 | jobs: 11 | release: 12 | name: Release - ${{ matrix.platform.os_name }} 13 | strategy: 14 | matrix: 15 | platform: 16 | - os_name: Linux-x86_64 17 | os: ubuntu-24.04 18 | target: x86_64-unknown-linux-musl 19 | bin: vpxtool 20 | name: vpxtool-Linux-x86_64-musl 21 | 22 | - os_name: Linux-aarch64 23 | os: ubuntu-24.04 24 | target: aarch64-unknown-linux-musl 25 | bin: vpxtool 26 | name: vpxtool-Linux-aarch64-musl 27 | 28 | - os_name: Windows-aarch64 29 | os: windows-latest 30 | target: aarch64-pc-windows-msvc 31 | bin: vpxtool.exe 32 | name: vpxtool-Windows-aarch64 33 | 34 | - os_name: Windows-x86_64 35 | os: windows-latest 36 | target: x86_64-pc-windows-msvc 37 | bin: vpxtool.exe 38 | name: vpxtool-Windows-x86_64 39 | 40 | - os_name: macOS-x86_64 41 | os: macos-latest 42 | target: x86_64-apple-darwin 43 | bin: vpxtool 44 | name: vpxtool-macOS-x86_64 45 | 46 | - os_name: macOS-aarch64 47 | os: macos-latest 48 | target: aarch64-apple-darwin 49 | bin: vpxtool 50 | name: vpxtool-macOS-aarch64 51 | 52 | runs-on: ${{ matrix.platform.os }} 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v6 56 | - name: Install minimal stable with clippy and rustfmt 57 | uses: dtolnay/rust-toolchain@stable 58 | with: 59 | toolchain: stable 60 | components: rustfmt, clippy 61 | target: ${{ matrix.platform.target }} 62 | - name: Build binary (*nix) 63 | shell: bash 64 | run: | 65 | cargo build --locked --release --target ${{ matrix.platform.target }} 66 | if: ${{ !contains(matrix.platform.os, 'windows') }} 67 | - name: Build binary (Windows) 68 | # We have to use the platform's native shell. If we use bash on 69 | # Windows then OpenSSL complains that the Perl it finds doesn't use 70 | # the platform's native paths and refuses to build. 71 | shell: powershell 72 | run: | 73 | & cargo build --locked --release --target ${{ matrix.platform.target }} 74 | if: contains(matrix.platform.os, 'windows') 75 | - name: Set full archive name as env variable 76 | shell: bash 77 | run: | 78 | VERSION=$(if [[ $GITHUB_REF == refs/tags/* ]]; then echo ${GITHUB_REF#refs/tags/} | sed 's/\//-/g'; else git rev-parse --short $GITHUB_SHA; fi) 79 | EXTENSION="tar.gz" 80 | if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then 81 | EXTENSION="zip" 82 | fi 83 | echo "ARCHIVE_NAME=${{ matrix.platform.name }}-${VERSION}.$EXTENSION" >> $GITHUB_ENV 84 | - name: Package as archive 85 | shell: bash 86 | run: | 87 | cd target/${{ matrix.platform.target }}/release 88 | if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then 89 | 7z a ../../../$ARCHIVE_NAME ${{ matrix.platform.bin }} 90 | else 91 | tar czvf ../../../$ARCHIVE_NAME ${{ matrix.platform.bin }} 92 | fi 93 | cd - 94 | - name: Publish release artifacts 95 | uses: actions/upload-artifact@v6 96 | with: 97 | name: ${{ env.ARCHIVE_NAME }} 98 | path: ${{ env.ARCHIVE_NAME }} 99 | if-no-files-found: error 100 | if: startsWith( github.ref, 'refs/tags/v' ) == false 101 | - name: Publish GitHub release 102 | uses: softprops/action-gh-release@v2 103 | with: 104 | files: "vpxtool*" 105 | if: startsWith( github.ref, 'refs/tags/v' ) 106 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fs::metadata; 2 | use std::io; 3 | use std::path::{Path, PathBuf}; 4 | 5 | mod backglass; 6 | pub mod fixprint; 7 | mod frontend; 8 | pub mod patcher; 9 | 10 | pub mod config; 11 | 12 | pub mod indexer; 13 | 14 | pub mod cli; 15 | mod colorful_theme_patched; 16 | pub mod vpinball_config; 17 | 18 | pub fn strip_cr_lf(s: &str) -> String { 19 | s.chars().filter(|c| !c.is_ascii_whitespace()).collect() 20 | } 21 | 22 | fn expand_path>(path: S) -> PathBuf { 23 | shellexpand::tilde(path.as_ref()).to_string().into() 24 | } 25 | 26 | fn expand_path_exists>(path: S) -> io::Result { 27 | // TODO expand all instead of only tilde? 28 | let expanded_path = shellexpand::tilde(path.as_ref()); 29 | path_exists(&PathBuf::from(expanded_path.to_string())) 30 | } 31 | 32 | fn path_exists(expanded_path: &Path) -> io::Result { 33 | match metadata(expanded_path) { 34 | Ok(md) => { 35 | if !md.is_file() && !md.is_dir() && md.is_symlink() { 36 | Err(io::Error::new( 37 | io::ErrorKind::InvalidInput, 38 | format!("{} is not a file", expanded_path.display()), 39 | )) 40 | } else { 41 | Ok(expanded_path.to_path_buf()) 42 | } 43 | } 44 | Err(msg) => { 45 | let warning = format!( 46 | "Failed to read metadata for {}: {}", 47 | expanded_path.display(), 48 | msg 49 | ); 50 | Err(io::Error::new(io::ErrorKind::InvalidInput, warning)) 51 | } 52 | } 53 | } 54 | 55 | fn os_independent_file_name(file_path: String) -> Option { 56 | // we can't use path here as this uses the system path encoding 57 | // we might have to parse windows paths on mac/linux 58 | if file_path.is_empty() { 59 | return None; 60 | } 61 | file_path.rsplit(['/', '\\']).next().map(|f| f.to_string()) 62 | } 63 | 64 | /// Path to file that will be removed when it goes out of scope 65 | struct RemoveOnDrop { 66 | path: PathBuf, 67 | } 68 | impl RemoveOnDrop { 69 | fn new(path: PathBuf) -> Self { 70 | RemoveOnDrop { path } 71 | } 72 | 73 | fn path(&self) -> &Path { 74 | &self.path 75 | } 76 | } 77 | 78 | impl Drop for RemoveOnDrop { 79 | fn drop(&mut self) { 80 | if self.path.exists() { 81 | // silently ignore any errors 82 | let _ = std::fs::remove_file(&self.path); 83 | } 84 | } 85 | } 86 | 87 | #[cfg(test)] 88 | mod tests { 89 | use super::*; 90 | 91 | #[test] 92 | fn test_os_independent_file_name_windows() { 93 | let file_path = "C:\\Users\\user\\Desktop\\file.txt"; 94 | let result = os_independent_file_name(file_path.to_string()); 95 | assert_eq!(result, Some("file.txt".to_string())); 96 | } 97 | 98 | #[test] 99 | fn test_os_independent_file_unix() { 100 | let file_path = "/users/joe/file.txt"; 101 | let result = os_independent_file_name(file_path.to_string()); 102 | assert_eq!(result, Some("file.txt".to_string())); 103 | } 104 | 105 | #[test] 106 | fn test_os_independent_file_name_no_extension() { 107 | let file_path = "C:\\Users\\user\\Desktop\\file"; 108 | let result = os_independent_file_name(file_path.to_string()); 109 | assert_eq!(result, Some("file".to_string())); 110 | } 111 | 112 | #[test] 113 | fn test_os_independent_file_name_no_path() { 114 | let file_path = "file.txt"; 115 | let result = os_independent_file_name(file_path.to_string()); 116 | assert_eq!(result, Some("file.txt".to_string())); 117 | } 118 | 119 | #[test] 120 | fn test_os_independent_file_name_no_path_no_extension() { 121 | let file_path = "file"; 122 | let result = os_independent_file_name(file_path.to_string()); 123 | assert_eq!(result, Some("file".to_string())); 124 | } 125 | 126 | #[test] 127 | fn test_os_independent_file_name_empty() { 128 | let file_path = ""; 129 | let result = os_independent_file_name(file_path.to_string()); 130 | assert_eq!(result, None); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/colorful_theme_patched.rs: -------------------------------------------------------------------------------- 1 | // Patched version of dialoguer::theme::ColorfulTheme 2 | // 3 | // This extends the colorful theme to partially fix a bug with the fuzzy select prompt rendering 4 | // where the ANSI escape codes were not being handled correctly. 5 | // 6 | // see https://github.com/console-rs/dialoguer/issues/312 7 | 8 | use dialoguer::theme::{ColorfulTheme, Theme}; 9 | use std::fmt; 10 | 11 | #[derive(Default)] 12 | pub struct ColorfulThemePatched { 13 | inner: ColorfulTheme, 14 | } 15 | 16 | impl Theme for ColorfulThemePatched { 17 | fn format_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 18 | self.inner.format_prompt(f, prompt) 19 | } 20 | 21 | fn format_error(&self, f: &mut dyn fmt::Write, err: &str) -> fmt::Result { 22 | self.inner.format_error(f, err) 23 | } 24 | 25 | fn format_confirm_prompt( 26 | &self, 27 | f: &mut dyn fmt::Write, 28 | prompt: &str, 29 | default: Option, 30 | ) -> fmt::Result { 31 | self.inner.format_confirm_prompt(f, prompt, default) 32 | } 33 | 34 | fn format_confirm_prompt_selection( 35 | &self, 36 | f: &mut dyn fmt::Write, 37 | prompt: &str, 38 | selection: Option, 39 | ) -> fmt::Result { 40 | self.inner 41 | .format_confirm_prompt_selection(f, prompt, selection) 42 | } 43 | 44 | fn format_input_prompt( 45 | &self, 46 | f: &mut dyn fmt::Write, 47 | prompt: &str, 48 | default: Option<&str>, 49 | ) -> fmt::Result { 50 | self.inner.format_input_prompt(f, prompt, default) 51 | } 52 | 53 | fn format_input_prompt_selection( 54 | &self, 55 | f: &mut dyn fmt::Write, 56 | prompt: &str, 57 | selection: &str, 58 | ) -> fmt::Result { 59 | self.inner 60 | .format_input_prompt_selection(f, prompt, selection) 61 | } 62 | 63 | fn format_password_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 64 | self.inner.format_password_prompt(f, prompt) 65 | } 66 | 67 | fn format_password_prompt_selection( 68 | &self, 69 | f: &mut dyn fmt::Write, 70 | prompt: &str, 71 | ) -> fmt::Result { 72 | self.inner.format_password_prompt_selection(f, prompt) 73 | } 74 | 75 | fn format_select_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 76 | self.inner.format_select_prompt(f, prompt) 77 | } 78 | 79 | fn format_select_prompt_selection( 80 | &self, 81 | f: &mut dyn fmt::Write, 82 | prompt: &str, 83 | selection: &str, 84 | ) -> fmt::Result { 85 | self.inner 86 | .format_select_prompt_selection(f, prompt, selection) 87 | } 88 | 89 | fn format_multi_select_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 90 | self.inner.format_multi_select_prompt(f, prompt) 91 | } 92 | 93 | fn format_sort_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 94 | self.inner.format_sort_prompt(f, prompt) 95 | } 96 | 97 | fn format_multi_select_prompt_selection( 98 | &self, 99 | f: &mut dyn fmt::Write, 100 | prompt: &str, 101 | selections: &[&str], 102 | ) -> fmt::Result { 103 | self.inner 104 | .format_multi_select_prompt_selection(f, prompt, selections) 105 | } 106 | 107 | fn format_sort_prompt_selection( 108 | &self, 109 | f: &mut dyn fmt::Write, 110 | prompt: &str, 111 | selections: &[&str], 112 | ) -> fmt::Result { 113 | self.inner 114 | .format_sort_prompt_selection(f, prompt, selections) 115 | } 116 | 117 | fn format_select_prompt_item( 118 | &self, 119 | f: &mut dyn fmt::Write, 120 | text: &str, 121 | active: bool, 122 | ) -> fmt::Result { 123 | self.inner.format_select_prompt_item(f, text, active) 124 | } 125 | 126 | fn format_multi_select_prompt_item( 127 | &self, 128 | f: &mut dyn fmt::Write, 129 | text: &str, 130 | checked: bool, 131 | active: bool, 132 | ) -> fmt::Result { 133 | self.inner 134 | .format_multi_select_prompt_item(f, text, checked, active) 135 | } 136 | 137 | fn format_sort_prompt_item( 138 | &self, 139 | f: &mut dyn fmt::Write, 140 | text: &str, 141 | picked: bool, 142 | active: bool, 143 | ) -> fmt::Result { 144 | self.inner.format_sort_prompt_item(f, text, picked, active) 145 | } 146 | 147 | fn format_fuzzy_select_prompt_item( 148 | &self, 149 | f: &mut dyn fmt::Write, 150 | text: &str, 151 | active: bool, 152 | highlight_matches: bool, 153 | matcher: &fuzzy_matcher::skim::SkimMatcherV2, 154 | search_term: &str, 155 | ) -> fmt::Result { 156 | if !active { 157 | return self.inner.format_fuzzy_select_prompt_item( 158 | f, 159 | text, 160 | active, 161 | highlight_matches, 162 | matcher, 163 | search_term, 164 | ); 165 | } 166 | // see https://github.com/console-rs/dialoguer/pull/336 167 | // we do not support highlight_matches=true here as it's broken anyway 168 | write!(f, "{} ", &self.inner.active_item_prefix)?; 169 | write!(f, "{}", self.inner.active_item_style.apply_to(text)) 170 | } 171 | 172 | fn format_fuzzy_select_prompt( 173 | &self, 174 | f: &mut dyn fmt::Write, 175 | prompt: &str, 176 | search_term: &str, 177 | cursor_pos: usize, 178 | ) -> fmt::Result { 179 | self.inner 180 | .format_fuzzy_select_prompt(f, prompt, search_term, cursor_pos) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vpxtool 2 | 3 | Cross-platform utility for the vpinball ecosystem 4 | 5 | Join [#vpxtool on "Virtual Pinball Chat" discord](https://discord.gg/ugFR9tCf2p) for support and questions. 6 | 7 | ## Install 8 | 9 | Download the latest release for your operating system at https://github.com/francisdb/vpxtool/releases, extract it and 10 | if wanted copy or symlink the binary to `$HOME/bin` to put in on your path 11 | 12 | ### macOS 13 | 14 | After extracting the archive you will have to remove the quarantine flag through 15 | `System Settings / Privacy & Security / Allow Anyway button` or on the command line as shown below. 16 | 17 | ``` 18 | xattr -d com.apple.quarantine vpxtool 19 | ``` 20 | 21 | ### Using cargo 22 | 23 | If you have [cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) installed you can install 24 | vpxtool with the following command: 25 | 26 | ``` 27 | cargo install vpxtool 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### Command Line Interface (CLI) 33 | 34 | Show help 35 | 36 | ```shell 37 | > vpxtool --help 38 | ``` 39 | 40 | ``` 41 | Vpxtool v0.16.0 42 | 43 | Extracts and assembles vpx files 44 | 45 | Usage: vpxtool [COMMAND] 46 | 47 | Commands: 48 | info Vpx table info related commands 49 | diff Prints out a diff between the vbs in the vpx and the sidecar vbs 50 | frontend Text based frontend for launching vpx files 51 | simplefrontend Simple text based frontend for launching vpx files 52 | index Indexes a directory of vpx files 53 | script Vpx script code related commands 54 | ls Show a vpx file content 55 | extract Extracts a vpx file 56 | extractvbs Extracts the vbs from a vpx file next to it 57 | importvbs Imports the vbs next to it into a vpx file 58 | verify Verify the structure of a vpx file 59 | assemble Assembles a vpx file 60 | patch Applies a VPURemix System patch to a table 61 | new Creates a minimal empty new vpx file 62 | config Vpxtool related config file 63 | images Vpx image related commands 64 | gamedata Vpx gamedata related commands 65 | romname Prints the PinMAME ROM name from a vpx file 66 | help Print this message or the help of the given subcommand(s) 67 | 68 | Options: 69 | -h, --help Print help 70 | -V, --version Print version 71 | ``` 72 | 73 | Show help for a specific command 74 | 75 | ```shell 76 | > vpxtool frontend --help` 77 | Acts as a frontend for launching vpx files 78 | 79 | Usage: vpxtool frontend [OPTIONS] [VPXROOTPATH] 80 | 81 | Arguments: 82 | [VPXROOTPATH] The path to the root directory of vpx files [default: /Users/myuser/vpinball/tables] 83 | 84 | Options: 85 | -r, --recursive Recursively index subdirectories 86 | -h, --help Print help 87 | ``` 88 | 89 | ### Logging 90 | 91 | To get more information about what vpxtool is doing, you can set the `-v` flag to increase verbosity. 92 | 93 | ```shell 94 | vpxtool -v extract test.vpx 95 | ``` 96 | 97 | You can also set the log level using the RUST_LOG environment variable. For example to get debug output: 98 | 99 | ```shell 100 | RUST_LOG=debug vpxtool extract test.vpx 101 | ``` 102 | 103 | ### Text UI Frontend 104 | 105 | Vpxtool can act as a frontend for launching vpx files. It will index a directory of vpx files and then present a menu to 106 | launch them. 107 | 108 | ``` 109 | > vpxtool frontend 110 | ``` 111 | 112 | ![Frontend](docs/frontend.png) 113 | 114 | ## Configuration 115 | 116 | A configuration file will be written to store among others the Visual Pinball executable location. The config file is 117 | using the [TOML](https://toml.io) format. 118 | 119 | When launching the frontend for the first time, it will help you to set up the required settings. 120 | 121 | To show the current config location, use the following command 122 | 123 | ``` 124 | vpxtool config path 125 | ``` 126 | 127 | To edit the config file using your system default editor, do the following 128 | 129 | ``` 130 | vpxtool config edit 131 | ``` 132 | 133 | ### Configuring vpinball paths 134 | 135 | ```yaml 136 | vpx_executable = "/home/myuser/vpinball/VPinballX_BGFX" 137 | 138 | # Optional settings below, only needed if the defaults don't work 139 | tables_folder = "/home/myuser/vpinball/tables" 140 | vpx_config = "/home/myuser/.vpinball/VPinballX.ini" 141 | ``` 142 | 143 | Further settings will be picked up from the Visual Pinball config. 144 | 145 | ### Launch templates 146 | 147 | Sometimes you want to use a different executables, extra arguments or environment variables. This can be done 148 | by setting up launch templates. Each entry will show up on top of the frontend table menu. 149 | 150 | ```toml 151 | [[launch_templates]] 152 | name = "Launch fullscreen" 153 | executable = "/home/myuser/vpinball/VPinballX_BGFX" 154 | arguments = ["-EnableTrueFullscreen"] 155 | 156 | [[launch_templates]] 157 | name = "Launch GL" 158 | executable = "/home/myuser/vpinball/VPinballX_GL" 159 | [launch_templates.env] 160 | SDL_VIDEODRIVER = "X11" 161 | ``` 162 | 163 | ### Configuring a custom editor 164 | 165 | When actions are invoked that open an editor, the default editor configured for your system will be used. In case you 166 | want to override this with a specific editor, you can add the following line to the config file: 167 | 168 | ```yaml 169 | # use Visual Studio Code as default editor 170 | editor = "code" 171 | ``` 172 | 173 | ## Projects using vpxtool 174 | 175 | * https://github.com/syd711/vpin-studio 176 | * https://github.com/jsm174/docker-vpxtool-resize 177 | * https://github.com/mpcarr/aztec-quest 178 | * https://github.com/francisdb/vpinball-example-table-extracted 179 | * https://github.com/surtarso/vpx-gui-tools 180 | 181 | ## References / Research 182 | 183 | Other related projects that read and/or assemble vpx files: 184 | 185 | * https://github.com/vpinball/vpinball 186 | * https://github.com/vpdb/vpx-js 187 | * https://github.com/freezy/VisualPinball.Engine 188 | * https://github.com/stojy/ClrVpin 189 | * https://github.com/vbousquet/vpx_lightmapper 190 | 191 | An example vpx managed in github with some imagemagick scripts to compose textures 192 | 193 | https://github.com/vbousquet/flexdmd/tree/master/FlexDemo 194 | 195 | ## Building 196 | 197 | The project uses the default [rust](https://www.rust-lang.org/) build tool `cargo`. To get going read the docs on 198 | installation and first steps at https://doc.rust-lang.org/cargo/ 199 | 200 | Some dependencies require extra dependencies. Make sure you install developer tools: 201 | 202 | * Fedora: `sudo dnf install @development-tools` 203 | * Ubuntu: `sudo apt install build-essential` 204 | 205 | ``` 206 | 207 | cargo build --release 208 | 209 | ``` 210 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.24.8](https://github.com/francisdb/vpxtool/compare/v0.24.7...v0.24.8) - 2025-12-14 11 | 12 | ### Other 13 | 14 | - *(deps)* bump vpin from 0.18.7 to 0.19.0 ([#643](https://github.com/francisdb/vpxtool/pull/643)) 15 | 16 | ## [0.24.7](https://github.com/francisdb/vpxtool/compare/v0.24.6...v0.24.7) - 2025-12-11 17 | 18 | ### Other 19 | 20 | - *(deps)* bump vpin from 0.18.6 to 0.18.7 ([#642](https://github.com/francisdb/vpxtool/pull/642)) 21 | - *(deps)* bump log from 0.4.28 to 0.4.29 ([#640](https://github.com/francisdb/vpxtool/pull/640)) 22 | - *(deps)* bump Swatinem/rust-cache from 2.8.1 to 2.8.2 ([#639](https://github.com/francisdb/vpxtool/pull/639)) 23 | - *(deps)* bump image from 0.25.8 to 0.25.9 ([#636](https://github.com/francisdb/vpxtool/pull/636)) 24 | - *(deps)* bump clap from 4.5.51 to 4.5.53 ([#635](https://github.com/francisdb/vpxtool/pull/635)) 25 | - *(deps)* bump indicatif from 0.18.2 to 0.18.3 ([#634](https://github.com/francisdb/vpxtool/pull/634)) 26 | - *(deps)* bump actions/checkout from 5 to 6 ([#637](https://github.com/francisdb/vpxtool/pull/637)) 27 | 28 | ## [0.24.6](https://github.com/francisdb/vpxtool/compare/v0.24.5...v0.24.6) - 2025-11-07 29 | 30 | ### Other 31 | 32 | - *(deps)* bump vpin from 0.18.3 to 0.18.6 ([#632](https://github.com/francisdb/vpxtool/pull/632)) 33 | 34 | ## [0.24.5](https://github.com/francisdb/vpxtool/compare/v0.24.4...v0.24.5) - 2025-11-07 35 | 36 | ### Fixed 37 | 38 | - diff for a local path ([#629](https://github.com/francisdb/vpxtool/pull/629)) 39 | 40 | ### Other 41 | 42 | - *(deps)* bump indicatif from 0.18.1 to 0.18.2 ([#627](https://github.com/francisdb/vpxtool/pull/627)) 43 | - *(deps)* bump clap from 4.5.50 to 4.5.51 ([#626](https://github.com/francisdb/vpxtool/pull/626)) 44 | - non-expiring discord invite link 45 | - *(deps)* bump vpin from 0.18.1 to 0.18.3 ([#622](https://github.com/francisdb/vpxtool/pull/622)) 46 | - *(deps)* bump indicatif from 0.18.0 to 0.18.1 ([#623](https://github.com/francisdb/vpxtool/pull/623)) 47 | - *(deps)* bump clap from 4.5.49 to 4.5.50 ([#624](https://github.com/francisdb/vpxtool/pull/624)) 48 | - *(deps)* bump actions/upload-artifact from 4 to 5 ([#625](https://github.com/francisdb/vpxtool/pull/625)) 49 | - *(deps)* bump clap from 4.5.48 to 4.5.49 ([#620](https://github.com/francisdb/vpxtool/pull/620)) 50 | - *(deps)* bump regex from 1.12.1 to 1.12.2 ([#621](https://github.com/francisdb/vpxtool/pull/621)) 51 | - *(deps)* bump regex from 1.11.3 to 1.12.1 ([#618](https://github.com/francisdb/vpxtool/pull/618)) 52 | 53 | ## [0.24.4](https://github.com/francisdb/vpxtool/compare/v0.24.3...v0.24.4) - 2025-10-10 54 | 55 | ### Added 56 | 57 | - *(frontend)* show dip switch info if available ([#617](https://github.com/francisdb/vpxtool/pull/617)) 58 | 59 | ### Other 60 | 61 | - *(deps)* bump toml from 0.9.7 to 0.9.8 ([#615](https://github.com/francisdb/vpxtool/pull/615)) 62 | - *(deps)* bump pinmame-nvram from 0.4.1 to 0.4.3 ([#614](https://github.com/francisdb/vpxtool/pull/614)) 63 | 64 | ## [0.24.3](https://github.com/francisdb/vpxtool/compare/v0.24.2...v0.24.3) - 2025-10-01 65 | 66 | ### Added 67 | 68 | - standard env logs if RUST_LOG is set 69 | 70 | ### Other 71 | 72 | - document logging 73 | - reverted custom vpin 74 | - verbosity flag added 75 | - *(deps)* bump toml from 0.9.5 to 0.9.7 ([#610](https://github.com/francisdb/vpxtool/pull/610)) 76 | - *(deps)* bump regex from 1.11.2 to 1.11.3 ([#609](https://github.com/francisdb/vpxtool/pull/609)) 77 | - *(deps)* bump serde from 1.0.223 to 1.0.228 ([#611](https://github.com/francisdb/vpxtool/pull/611)) 78 | - *(deps)* bump clap from 4.5.47 to 4.5.48 ([#612](https://github.com/francisdb/vpxtool/pull/612)) 79 | - *(deps)* bump Swatinem/rust-cache from 2.8.0 to 2.8.1 ([#606](https://github.com/francisdb/vpxtool/pull/606)) 80 | - fix lock file 81 | - *(deps)* bump console from 0.16.0 to 0.16.1 ([#603](https://github.com/francisdb/vpxtool/pull/603)) 82 | - *(deps)* bump chrono from 0.4.41 to 0.4.42 ([#604](https://github.com/francisdb/vpxtool/pull/604)) 83 | - *(deps)* bump serde_json from 1.0.143 to 1.0.145 ([#605](https://github.com/francisdb/vpxtool/pull/605)) 84 | - *(deps)* bump log from 0.4.27 to 0.4.28 ([#597](https://github.com/francisdb/vpxtool/pull/597)) 85 | - *(deps)* bump image from 0.25.7 to 0.25.8 ([#596](https://github.com/francisdb/vpxtool/pull/596)) 86 | - *(deps)* bump clap from 4.5.46 to 4.5.47 ([#595](https://github.com/francisdb/vpxtool/pull/595)) 87 | 88 | ## [0.24.2](https://github.com/francisdb/vpxtool/compare/v0.24.1...v0.24.2) - 2025-09-03 89 | 90 | ### Fixed 91 | 92 | - search ansi escape issues ([#594](https://github.com/francisdb/vpxtool/pull/594)) 93 | 94 | ### Other 95 | 96 | - *(deps)* bump image from 0.25.6 to 0.25.7 ([#590](https://github.com/francisdb/vpxtool/pull/590)) 97 | - *(deps)* bump is_executable from 1.0.4 to 1.0.5 ([#591](https://github.com/francisdb/vpxtool/pull/591)) 98 | - *(deps)* bump rust-ini from 0.21.2 to 0.21.3 ([#592](https://github.com/francisdb/vpxtool/pull/592)) 99 | - *(deps)* bump clap from 4.5.45 to 4.5.46 ([#589](https://github.com/francisdb/vpxtool/pull/589)) 100 | - *(deps)* bump dialoguer from 0.11.0 to 0.12.0 ([#588](https://github.com/francisdb/vpxtool/pull/588)) 101 | - *(deps)* bump serde_json from 1.0.142 to 1.0.143 ([#587](https://github.com/francisdb/vpxtool/pull/587)) 102 | - *(deps)* bump regex from 1.11.1 to 1.11.2 ([#586](https://github.com/francisdb/vpxtool/pull/586)) 103 | - *(deps)* bump actions/checkout from 4 to 5 ([#584](https://github.com/francisdb/vpxtool/pull/584)) 104 | - *(deps)* bump rayon from 1.10.0 to 1.11.0 ([#585](https://github.com/francisdb/vpxtool/pull/585)) 105 | - *(deps)* bump toml from 0.9.4 to 0.9.5 ([#582](https://github.com/francisdb/vpxtool/pull/582)) 106 | - *(deps)* bump clap from 4.5.42 to 4.5.43 ([#581](https://github.com/francisdb/vpxtool/pull/581)) 107 | - new clippy rules ([#583](https://github.com/francisdb/vpxtool/pull/583)) 108 | - *(deps)* bump serde_json from 1.0.141 to 1.0.142 ([#578](https://github.com/francisdb/vpxtool/pull/578)) 109 | - *(deps)* bump clap from 4.5.41 to 4.5.42 ([#579](https://github.com/francisdb/vpxtool/pull/579)) 110 | - *(deps)* bump toml from 0.9.2 to 0.9.4 ([#580](https://github.com/francisdb/vpxtool/pull/580)) 111 | - *(deps)* bump rand from 0.9.1 to 0.9.2 ([#575](https://github.com/francisdb/vpxtool/pull/575)) 112 | - *(deps)* bump serde_json from 1.0.140 to 1.0.141 ([#576](https://github.com/francisdb/vpxtool/pull/576)) 113 | 114 | ## [0.24.1](https://github.com/francisdb/vpxtool/compare/v0.24.0...v0.24.1) - 2025-07-14 115 | 116 | ### Other 117 | 118 | - *(deps)* bump pinmame-nvram from 0.3.18 to 0.4.1 ([#572](https://github.com/francisdb/vpxtool/pull/572)) 119 | - *(deps)* bump toml from 0.8.23 to 0.9.2 ([#573](https://github.com/francisdb/vpxtool/pull/573)) 120 | - *(deps)* bump clap from 4.5.40 to 4.5.41 ([#571](https://github.com/francisdb/vpxtool/pull/571)) 121 | - *(deps)* bump indicatif from 0.17.11 to 0.18.0 ([#569](https://github.com/francisdb/vpxtool/pull/569)) 122 | - *(deps)* bump rust-ini from 0.21.1 to 0.21.2 ([#570](https://github.com/francisdb/vpxtool/pull/570)) 123 | - *(deps)* bump Swatinem/rust-cache from 2.7.8 to 2.8.0 ([#567](https://github.com/francisdb/vpxtool/pull/567)) 124 | - *(deps)* bump console from 0.15.11 to 0.16.0 ([#566](https://github.com/francisdb/vpxtool/pull/566)) 125 | - new clippy rules ([#568](https://github.com/francisdb/vpxtool/pull/568)) 126 | - *(deps)* bump pinmame-nvram from 0.3.17 to 0.3.18 ([#565](https://github.com/francisdb/vpxtool/pull/565)) 127 | - *(deps)* bump pinmame-nvram from 0.3.16 to 0.3.17 ([#562](https://github.com/francisdb/vpxtool/pull/562)) 128 | - *(deps)* bump clap from 4.5.39 to 4.5.40 ([#563](https://github.com/francisdb/vpxtool/pull/563)) 129 | - Fix typo in launch_templates doc in README ([#561](https://github.com/francisdb/vpxtool/pull/561)) 130 | - *(deps)* bump toml from 0.8.22 to 0.8.23 ([#560](https://github.com/francisdb/vpxtool/pull/560)) 131 | - *(deps)* bump clap from 4.5.38 to 4.5.39 ([#559](https://github.com/francisdb/vpxtool/pull/559)) 132 | - new clippy rules ([#558](https://github.com/francisdb/vpxtool/pull/558)) 133 | - linux developer dependencies 134 | - *(deps)* bump clap from 4.5.37 to 4.5.38 ([#557](https://github.com/francisdb/vpxtool/pull/557)) 135 | - *(deps)* bump toml from 0.8.21 to 0.8.22 ([#555](https://github.com/francisdb/vpxtool/pull/555)) 136 | - *(deps)* bump chrono from 0.4.40 to 0.4.41 ([#554](https://github.com/francisdb/vpxtool/pull/554)) 137 | 138 | ## [0.24.0](https://github.com/francisdb/vpxtool/compare/v0.23.5...v0.24.0) - 2025-04-29 139 | 140 | ### Added 141 | 142 | - launch templates ([#548](https://github.com/francisdb/vpxtool/pull/548)) 143 | 144 | ### Other 145 | 146 | - ignore release step for dependabot 147 | - ignore release step for dependabot 148 | - *(deps)* bump toml from 0.8.20 to 0.8.21 ([#550](https://github.com/francisdb/vpxtool/pull/550)) 149 | 150 | ## [0.23.5](https://github.com/francisdb/vpxtool/compare/v0.23.4...v0.23.5) - 2025-04-21 151 | 152 | ### Fixed 153 | 154 | - --output-dir vbs file name was missing a part ([#547](https://github.com/francisdb/vpxtool/pull/547)) 155 | 156 | ### Other 157 | 158 | - *(deps)* bump rand from 0.9.0 to 0.9.1 ([#544](https://github.com/francisdb/vpxtool/pull/544)) 159 | - *(deps)* bump pinmame-nvram from 0.3.15 to 0.3.16 ([#543](https://github.com/francisdb/vpxtool/pull/543)) 160 | - *(deps)* bump clap from 4.5.36 to 4.5.37 ([#545](https://github.com/francisdb/vpxtool/pull/545)) 161 | - *(deps)* bump shellexpand from 3.1.0 to 3.1.1 ([#546](https://github.com/francisdb/vpxtool/pull/546)) 162 | - remove bevy linux deps installation 163 | - missed one more deprecated github action step 164 | 165 | ## [0.23.4](https://github.com/francisdb/vpxtool/compare/v0.23.3...v0.23.4) - 2025-04-15 166 | 167 | ### Other 168 | 169 | - work around github further workflow runs limit 170 | 171 | ## [0.23.3](https://github.com/francisdb/vpxtool/compare/v0.23.2...v0.23.3) - 2025-04-15 172 | 173 | ### Other 174 | 175 | - switch to dtolnay/rust-toolchain instead of actions-rs/toolchain ([#539](https://github.com/francisdb/vpxtool/pull/539)) 176 | - remove docs on gui frontend that was removed 177 | 178 | ## [0.23.2](https://github.com/francisdb/vpxtool/compare/v0.23.1...v0.23.2) - 2025-04-15 179 | 180 | ### Other 181 | 182 | - do not fail build if git is not available 183 | 184 | ## [0.23.1](https://github.com/francisdb/vpxtool/compare/v0.23.0...v0.23.1) - 2025-04-15 185 | 186 | ### Other 187 | 188 | - add missing cargo.toml fields required for release 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/vpinball_config.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use std::fmt::Display; 3 | use std::io; 4 | use std::io::Read; 5 | use std::path::Path; 6 | 7 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 8 | pub enum WindowType { 9 | Playfield, 10 | PinMAME, 11 | FlexDMD, 12 | B2SBackglass, 13 | /// FullDMD 14 | B2SDMD, 15 | PUPTopper, 16 | PUPBackglass, 17 | PUPDMD, 18 | PUPPlayfield, 19 | PUPFullDMD, 20 | DMD, 21 | } 22 | impl Display for WindowType { 23 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 | match self { 25 | WindowType::Playfield => write!(f, "Playfield"), 26 | WindowType::PinMAME => write!(f, "PinMAME"), 27 | WindowType::FlexDMD => write!(f, "FlexDMD"), 28 | WindowType::B2SBackglass => write!(f, "B2SBackglass"), 29 | WindowType::B2SDMD => write!(f, "B2SDMD"), 30 | WindowType::PUPTopper => write!(f, "PUPTopper"), 31 | WindowType::PUPBackglass => write!(f, "PUPBackglass"), 32 | WindowType::PUPDMD => write!(f, "PUPDMD"), 33 | WindowType::PUPPlayfield => write!(f, "PUPPlayfield"), 34 | WindowType::PUPFullDMD => write!(f, "PUPFullDMD"), 35 | WindowType::DMD => write!(f, "DMD"), 36 | } 37 | } 38 | } 39 | 40 | fn config_prefix(window_type: WindowType) -> &'static str { 41 | match window_type { 42 | WindowType::Playfield => "Playfield", 43 | WindowType::PinMAME => "PinMAMEWindow", 44 | WindowType::FlexDMD => "FlexDMDWindow", 45 | WindowType::B2SBackglass => "B2SBackglass", 46 | WindowType::B2SDMD => "B2SDMD", 47 | WindowType::PUPTopper => "PUPTopperWindow", 48 | WindowType::PUPBackglass => "PUPBackglassWindow", 49 | WindowType::PUPDMD => "PUPDMDWindow", 50 | WindowType::PUPPlayfield => "PUPPlayfieldWindow", 51 | WindowType::PUPFullDMD => "PUPFullDMDWindow", 52 | WindowType::DMD => "DMD", 53 | } 54 | } 55 | 56 | fn section_name(window_type: WindowType) -> String { 57 | match window_type { 58 | WindowType::Playfield => "Player".to_string(), 59 | WindowType::DMD => "DMD".to_string(), 60 | _ => "Standalone".to_string(), 61 | } 62 | } 63 | 64 | /// FullScreen = 0 65 | /// PlayfieldFullScreen = 0 66 | /// WindowPosX = 67 | /// PlayfieldWindowPosX = 68 | /// WindowPosY = 69 | /// PlayfieldWindowPosY = 70 | /// Width = 540 71 | /// PlayfieldWidth = 540 72 | /// Height = 960 73 | /// PlayfieldHeight = 960 74 | /// 75 | /// Note: For macOS with hidpi screen this these are logical sizes/locations, not pixel sizes 76 | #[derive(Debug, Clone)] 77 | pub struct WindowInfo { 78 | pub fullscreen: Option, 79 | pub x: Option, 80 | pub y: Option, 81 | pub width: Option, 82 | pub height: Option, 83 | } 84 | 85 | pub struct VPinballConfig { 86 | ini: ini::Ini, 87 | } 88 | 89 | impl Default for VPinballConfig { 90 | fn default() -> Self { 91 | Self::new() 92 | } 93 | } 94 | 95 | impl VPinballConfig { 96 | pub fn new() -> Self { 97 | VPinballConfig { 98 | ini: ini::Ini::new(), 99 | } 100 | } 101 | 102 | pub fn read(ini_path: &Path) -> io::Result { 103 | info!("Reading vpinball ini file: {ini_path:?}"); 104 | let ini = ini::Ini::load_from_file(ini_path).map_err(|e| { 105 | io::Error::new( 106 | io::ErrorKind::InvalidData, 107 | format!("Failed to read ini file: {e:?}"), 108 | ) 109 | })?; 110 | Ok(VPinballConfig { ini }) 111 | } 112 | 113 | pub fn read_from(reader: &mut R) -> io::Result { 114 | let ini = ini::Ini::read_from(reader).map_err(|e| { 115 | io::Error::new( 116 | io::ErrorKind::InvalidData, 117 | format!("Failed to read ini file: {e:?}"), 118 | ) 119 | })?; 120 | Ok(VPinballConfig { ini }) 121 | } 122 | 123 | pub fn write(&self, ini_path: &Path) -> io::Result<()> { 124 | self.ini.write_to_file(ini_path) 125 | } 126 | 127 | pub fn write_to(&self, writer: &mut W) -> io::Result<()> { 128 | self.ini.write_to(writer) 129 | } 130 | 131 | pub fn get_pinmame_path(&self) -> Option { 132 | if let Some(standalone_section) = self.ini.section(Some("Standalone")) { 133 | standalone_section.get("PinMAMEPath").map(|s| s.to_string()) 134 | } else { 135 | None 136 | } 137 | } 138 | 139 | pub fn is_window_enabled(&self, window_type: WindowType) -> bool { 140 | match window_type { 141 | WindowType::Playfield => true, 142 | WindowType::DMD => { 143 | let section = section_name(window_type); 144 | if let Some(ini_section) = self.ini.section(Some(section)) { 145 | let prefix = config_prefix(window_type); 146 | ini_section.get(format!("{prefix}Output")) == Some("2") 147 | } else { 148 | false 149 | } 150 | } 151 | WindowType::B2SBackglass => { 152 | let section = section_name(window_type); 153 | if let Some(ini_section) = self.ini.section(Some(section)) { 154 | // TODO what are the defaults here> 155 | ini_section.get("B2SWindows") == Some("1") 156 | } else { 157 | false 158 | } 159 | } 160 | WindowType::B2SDMD => { 161 | let section = section_name(window_type); 162 | if let Some(ini_section) = self.ini.section(Some(section)) { 163 | // TODO what are the defaults here> 164 | ini_section.get("B2SWindows") == Some("1") 165 | && ini_section.get("B2SHideB2SDMD") == Some("0") 166 | } else { 167 | false 168 | } 169 | } 170 | WindowType::PUPDMD 171 | | WindowType::PUPBackglass 172 | | WindowType::PUPTopper 173 | | WindowType::PUPFullDMD 174 | | WindowType::PUPPlayfield => { 175 | let section = section_name(window_type); 176 | if let Some(ini_section) = self.ini.section(Some(section)) { 177 | ini_section.get("PUPWindows") == Some("1") 178 | } else { 179 | false 180 | } 181 | } 182 | WindowType::FlexDMD | WindowType::PinMAME => { 183 | let section = section_name(window_type); 184 | let prefix = config_prefix(window_type); 185 | self.ini.section(Some(section)).is_some_and(|ini_section| { 186 | ini_section.get(format!("{prefix}Window")) == Some("1") 187 | }) 188 | } 189 | } 190 | } 191 | 192 | pub fn get_window_info(&self, window_type: WindowType) -> Option { 193 | let section = section_name(window_type); 194 | match window_type { 195 | WindowType::Playfield => { 196 | if let Some(ini_section) = self.ini.section(Some(section)) { 197 | // get all the values from PlayfieldXXX and fall back to the normal values 198 | let fullscreen = match ini_section.get("PlayfieldFullScreen") { 199 | Some("1") => Some(true), 200 | Some("0") => Some(false), 201 | Some(empty) if empty.trim().is_empty() => None, 202 | Some(other) => { 203 | log::warn!("Unexpected value for PlayfieldFullScreen: {other}"); 204 | None 205 | } 206 | None => match ini_section.get("FullScreen") { 207 | Some("1") => Some(true), 208 | Some("0") => Some(false), 209 | Some(empty) if empty.trim().is_empty() => None, 210 | Some(other) => { 211 | log::warn!("Unexpected value for FullScreen: {other}"); 212 | None 213 | } 214 | None => None, 215 | }, 216 | }; 217 | let x = ini_section 218 | .get("PlayfieldWndX") 219 | .or_else(|| ini_section.get("WindowPosX")) 220 | .and_then(|s| s.parse::().ok()); 221 | 222 | let y = ini_section 223 | .get("PlayfieldWndY") 224 | .or_else(|| ini_section.get("WindowPosY")) 225 | .and_then(|s| s.parse::().ok()); 226 | 227 | let width = ini_section 228 | .get("PlayfieldWidth") 229 | .or_else(|| ini_section.get("Width")) 230 | .and_then(|s| s.parse::().ok()); 231 | 232 | let height = ini_section 233 | .get("PlayfieldHeight") 234 | .or_else(|| ini_section.get("Height")) 235 | .and_then(|s| s.parse::().ok()); 236 | 237 | Some(WindowInfo { 238 | fullscreen, 239 | x, 240 | y, 241 | width, 242 | height, 243 | }) 244 | } else { 245 | None 246 | } 247 | } 248 | other => self.lookup_window_info(other), 249 | } 250 | } 251 | 252 | pub fn set_window_position(&mut self, window_type: WindowType, x: u32, y: u32) { 253 | let section = section_name(window_type); 254 | let prefix = config_prefix(window_type); 255 | // preferably we would write a comment but the ini crate does not support that 256 | // see https://github.com/zonyitoo/rust-ini/issues/77 257 | 258 | let x_suffix = match window_type { 259 | WindowType::Playfield | WindowType::DMD => "WndX", 260 | _ => "X", 261 | }; 262 | let y_suffix = match window_type { 263 | WindowType::Playfield | WindowType::DMD => "WndY", 264 | _ => "Y", 265 | }; 266 | 267 | let x_key = format!("{prefix}{x_suffix}"); 268 | let y_key = format!("{prefix}{y_suffix}"); 269 | 270 | self.ini 271 | .with_section(Some(§ion)) 272 | .set(x_key, x.to_string()) 273 | .set(y_key, y.to_string()); 274 | } 275 | 276 | pub fn set_window_size(&mut self, window_type: WindowType, width: u32, height: u32) { 277 | let section = section_name(window_type); 278 | let prefix = config_prefix(window_type); 279 | 280 | let width_key = format!("{}{}", prefix, "Width"); 281 | let height_key = format!("{}{}", prefix, "Height"); 282 | 283 | self.ini 284 | .with_section(Some(§ion)) 285 | .set(width_key, width.to_string()) 286 | .set(height_key, height.to_string()); 287 | } 288 | 289 | fn lookup_window_info(&self, window_type: WindowType) -> Option { 290 | let section = section_name(window_type); 291 | if let Some(ini_section) = self.ini.section(Some(section)) { 292 | let prefix = config_prefix(window_type); 293 | let fullscreen = ini_section 294 | .get(format!("{}{}", prefix, "FullScreen")) 295 | .map(|s| s == "1"); 296 | let x = ini_section 297 | .get(format!("{}{}", prefix, "X")) 298 | .and_then(|s| s.parse::().ok()); 299 | let y = ini_section 300 | .get(format!("{}{}", prefix, "Y")) 301 | .and_then(|s| s.parse::().ok()); 302 | let width = ini_section 303 | .get(format!("{}{}", prefix, "Width")) 304 | .and_then(|s| s.parse::().ok()); 305 | let height = ini_section 306 | .get(format!("{}{}", prefix, "Height")) 307 | .and_then(|s| s.parse::().ok()); 308 | if x.is_none() && y.is_none() && width.is_none() && height.is_none() { 309 | return None; 310 | } 311 | Some(WindowInfo { 312 | fullscreen, 313 | x, 314 | y, 315 | width, 316 | height, 317 | }) 318 | } else { 319 | None 320 | } 321 | } 322 | } 323 | 324 | #[cfg(test)] 325 | mod tests { 326 | use super::*; 327 | use pretty_assertions::assert_eq; 328 | use testdir::testdir; 329 | 330 | #[test] 331 | fn test_read_vpinball_config() { 332 | let testdir = testdir!(); 333 | // manually create test ini file 334 | let ini_path = testdir.join("test.ini"); 335 | std::fs::write( 336 | &ini_path, 337 | r#" 338 | [Player] 339 | FullScreen=1 340 | PlayfieldFullScreen=1 341 | PlayfieldWndX=0 342 | PlayfieldWndY=0 343 | PlayfieldWidth=1920 344 | PlayfieldHeight=1080 345 | "#, 346 | ) 347 | .unwrap(); 348 | 349 | let config = VPinballConfig::read(&ini_path).unwrap(); 350 | assert_eq!( 351 | config 352 | .get_window_info(WindowType::Playfield) 353 | .unwrap() 354 | .fullscreen, 355 | Some(true) 356 | ); 357 | assert_eq!( 358 | config.get_window_info(WindowType::Playfield).unwrap().x, 359 | Some(0) 360 | ); 361 | assert_eq!( 362 | config.get_window_info(WindowType::Playfield).unwrap().y, 363 | Some(0) 364 | ); 365 | assert_eq!( 366 | config.get_window_info(WindowType::Playfield).unwrap().width, 367 | Some(1920) 368 | ); 369 | assert_eq!( 370 | config 371 | .get_window_info(WindowType::Playfield) 372 | .unwrap() 373 | .height, 374 | Some(1080) 375 | ); 376 | } 377 | 378 | #[test] 379 | fn test_write_vpinball_config() { 380 | let mut config = VPinballConfig::default(); 381 | config.set_window_position(WindowType::Playfield, 100, 200); 382 | config.set_window_size(WindowType::Playfield, 300, 400); 383 | let mut cursor = io::Cursor::new(Vec::new()); 384 | config.write_to(&mut cursor).unwrap(); 385 | cursor.set_position(0); 386 | let config_read = VPinballConfig::read_from(&mut cursor).unwrap(); 387 | assert_eq!( 388 | config_read 389 | .get_window_info(WindowType::Playfield) 390 | .unwrap() 391 | .x, 392 | Some(100) 393 | ); 394 | assert_eq!( 395 | config_read 396 | .get_window_info(WindowType::Playfield) 397 | .unwrap() 398 | .y, 399 | Some(200) 400 | ); 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/patcher.rs: -------------------------------------------------------------------------------- 1 | //! Patcher for typical standalone vbs issues 2 | 3 | use regex::Regex; 4 | use std::collections::HashSet; 5 | use std::fmt::Display; 6 | use std::fs::File; 7 | use std::io; 8 | use std::io::{Read, Write}; 9 | use std::path::Path; 10 | 11 | #[derive(Debug, PartialEq, Eq, Hash)] 12 | pub enum LineEndingsResult { 13 | NoChanges, 14 | Unified, 15 | } 16 | 17 | #[derive(Debug, PartialEq, Eq, Hash)] 18 | pub enum PatchType { 19 | DropTarget, 20 | StandupTarget, 21 | } 22 | impl Display for PatchType { 23 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 | match self { 25 | PatchType::DropTarget => write!(f, "DTArray fix"), 26 | PatchType::StandupTarget => write!(f, "STArray fix"), 27 | } 28 | } 29 | } 30 | 31 | pub fn patch_vbs_file(vbs_path: &Path) -> io::Result> { 32 | // TODO we probably need to ensure proper encoding here in stead of going for utf8 33 | let mut file = File::open(vbs_path)?; 34 | let mut text = String::new(); 35 | file.read_to_string(&mut text)?; 36 | 37 | let (patched_text, applied) = patch_script(text); 38 | 39 | let mut file = File::create(vbs_path)?; 40 | file.write_all(patched_text.as_bytes())?; 41 | Ok(applied) 42 | } 43 | 44 | /** 45 | * This function will unify the line endings of a vbs file to \r\n 46 | * This is needed because some vbs files have mixed line endings, which causes 47 | * the vbs parser to fail. 48 | * One example is [Aztec (Williams 1976) 1.3 by jipeji16](https://www.vpforums.org/index.php?app=downloads&showfile=15768) 49 | */ 50 | pub fn unify_line_endings_vbs_file(vbs_path: &Path) -> io::Result { 51 | // TODO we probably need to ensure proper encoding here in stead of going for utf8 52 | let mut file = File::open(vbs_path)?; 53 | let mut text = String::new(); 54 | file.read_to_string(&mut text)?; 55 | 56 | let patched_text = unify_line_endings(&text); 57 | 58 | let mut file = File::create(vbs_path)?; 59 | file.write_all(patched_text.as_bytes())?; 60 | 61 | if text != patched_text { 62 | Ok(LineEndingsResult::Unified) 63 | } else { 64 | Ok(LineEndingsResult::NoChanges) 65 | } 66 | } 67 | 68 | pub fn patch_script(script: String) -> (String, HashSet) { 69 | // TODO we could work with regex::bytes::Regex instead to avoid the conversion to utf8 70 | 71 | let mut applied_patches = HashSet::new(); 72 | let mut patched_script = script; 73 | 74 | if patched_script.contains("DTArray(i)(0)") { 75 | applied_patches.insert(PatchType::DropTarget); 76 | patched_script = patch_drop_target_array(patched_script); 77 | } 78 | 79 | if patched_script.contains("STArray(i)(0)") { 80 | applied_patches.insert(PatchType::StandupTarget); 81 | patched_script = patch_standup_target_array(patched_script); 82 | } 83 | 84 | //todo!("implement patching"); 85 | (patched_script, applied_patches) 86 | } 87 | 88 | fn unify_line_endings(script: &str) -> String { 89 | // first replace all \r\n with \n 90 | // then replace all \r with \n (this is the main issue, as some files have mixed \r\n and \r) 91 | // then go back to standard vbs line endings \r\n 92 | script 93 | .replace("\r\n", "\n") 94 | .replace('\r', "\n") 95 | .replace('\n', "\r\n") 96 | } 97 | 98 | fn patch_standup_target_array(script: String) -> String { 99 | // apply the following replacements 100 | 101 | // ST41 = Array(sw41, Target_Rect_Fat_011_BM_Lit_Room, 41, 0) 102 | // becomes 103 | // Set ST41 = (new StandupTarget)(sw41, Target_Rect_Fat_011_BM_Lit_Room, 41, 0) 104 | // The fact that it ends with a number is important, to not match the target list array 105 | let re = Regex::new(r"(ST[a-zA-Z0-9]*\s*=\s*)Array\((.*?\s*,\s*[0-9]+\s*)\)").unwrap(); 106 | let mut patched_script = re 107 | .replace_all(&script, |caps: ®ex::Captures| { 108 | let ind = caps.get(1).unwrap().as_str(); 109 | let ind2 = caps.get(2).unwrap().as_str(); 110 | format!("Set {ind}(new StandupTarget)({ind2})") 111 | }) 112 | .to_string(); 113 | 114 | let st_class = include_str!("assets/standup_target_class.vbs"); 115 | let marker = "'Define a variable for each stand-up target"; 116 | patched_script = introduce_class(patched_script, marker, "new StandupTarget", st_class); 117 | 118 | patched_script = patched_script.replace("STArray(i)(0)", "STArray(i).primary"); 119 | patched_script = patched_script.replace("STArray(i)(1)", "STArray(i).prim"); 120 | patched_script = patched_script.replace("STArray(i)(2)", "STArray(i).sw"); 121 | patched_script = patched_script.replace("STArray(i)(3)", "STArray(i).animate"); 122 | patched_script 123 | } 124 | 125 | fn patch_drop_target_array(script: String) -> String { 126 | // DT7 = Array(dt1, dt1a, pdt1, 7, 0) 127 | // DT27 = Array(dt2, dt2a, pdt2, 27, 0, false) 128 | // becomes 129 | // Set DT7 = (new DropTarget)(dt1, dt1a, pdt1, 7, 0, false) 130 | // Set DT27 = (new DropTarget)(dt2, dt2a, pdt2, 27, 0, false) 131 | // The fact that it ends with a number and optional boolean is important, to not match the target list array 132 | let re = 133 | Regex::new(r"(DT[a-zA-Z0-9]*\s*=\s*)Array\((.*?\s*,\s*[0-9]+\s*)(,\s*(false|true))?\)") 134 | .unwrap(); 135 | let mut patched_script = re 136 | .replace_all(&script, |caps: ®ex::Captures| { 137 | let ind = caps.get(1).unwrap().as_str(); 138 | let ind2 = caps.get(2).unwrap().as_str(); 139 | let ind3 = caps.get(3); 140 | let false_true = match ind3 { 141 | Some(c) => c.as_str().to_string(), 142 | None => ", false".to_string(), 143 | }; 144 | format!("Set {ind}(new DropTarget)({ind2}{false_true})") 145 | }) 146 | .to_string(); 147 | 148 | let dt_class = include_str!("assets/drop_target_class.vbs"); 149 | let marker = "'Define a variable for each drop target"; 150 | patched_script = introduce_class(patched_script, marker, "new DropTarget", dt_class); 151 | 152 | patched_script = patched_script.replace("DTArray(i)(0)", "DTArray(i).primary"); 153 | patched_script = patched_script.replace("DTArray(i)(1)", "DTArray(i).secondary"); 154 | patched_script = patched_script.replace("DTArray(i)(2)", "DTArray(i).prim"); 155 | patched_script = patched_script.replace("DTArray(i)(3)", "DTArray(i).sw"); 156 | patched_script = patched_script.replace("DTArray(i)(4)", "DTArray(i).animate"); 157 | 158 | // TODO we could work with a regex to catch all cases 159 | patched_script = patched_script.replace("DTArray(i)(5)", "DTArray(i).isDropped"); 160 | patched_script = patched_script.replace("DTArray(ind)(5)", "DTArray(ind).isDropped"); 161 | patched_script 162 | } 163 | 164 | fn introduce_class(script: String, marker: &str, fallback_marker: &str, class_def: &str) -> String { 165 | if script.match_indices(marker).count() == 1 { 166 | script.replace(marker, format!("{class_def}\r\n{marker}").as_str()) 167 | } else { 168 | // Put class_def before the first line that contains "new DropTarget" 169 | // which we previously added. 170 | let regex = format!(r"\r\n(.*?)({fallback_marker})"); 171 | let re = Regex::new(regex.as_ref()).unwrap(); 172 | if re.is_match(&script) { 173 | re.replace(&script, |caps: ®ex::Captures| { 174 | let first = caps.get(1).unwrap().as_str(); 175 | let second = caps.get(2).unwrap().as_str(); 176 | format!("\r\n{class_def}\r\n{first}{second}") 177 | }) 178 | .to_string() 179 | } else { 180 | // No better location found, append the class at the end of the file. 181 | format!("{script}\r\n{class_def}") 182 | } 183 | } 184 | } 185 | 186 | #[cfg(test)] 187 | mod tests { 188 | use super::*; 189 | use pretty_assertions::assert_eq; 190 | 191 | #[test] 192 | fn test_unify_line_endings() { 193 | let script = "first\nsecond\r\nthird\rfourth"; 194 | let expected = "first\r\nsecond\r\nthird\r\nfourth"; 195 | 196 | let result = unify_line_endings(script); 197 | 198 | assert_eq!(expected, result); 199 | } 200 | 201 | #[test] 202 | fn test_introduce_class_at_marker() { 203 | let script = r#" 204 | hello 205 | this is the line 206 | this is the other line 207 | end"#; 208 | let marker = "this is the line"; 209 | let fallback_marker = "other"; 210 | let class_def = "Class Foo\r\nEnd Class\r\n"; 211 | let expected = r#" 212 | hello 213 | Class Foo 214 | End Class 215 | 216 | this is the line 217 | this is the other line 218 | end"#; 219 | let script = script.replace('\n', "\r\n"); 220 | let expected = expected.replace('\n', "\r\n"); 221 | 222 | let result = introduce_class(script.to_string(), marker, fallback_marker, class_def); 223 | 224 | assert_eq!(expected, result); 225 | } 226 | 227 | #[test] 228 | fn test_introduce_class_at_fallback() { 229 | let script = r#" 230 | hello 231 | this is the line 232 | this is the other line 233 | end"#; 234 | let marker = "missing"; 235 | let fallback_marker = "other"; 236 | let class_def = "Class Foo\r\nEnd Class\r\n"; 237 | let expected = r#" 238 | hello 239 | this is the line 240 | Class Foo 241 | End Class 242 | 243 | this is the other line 244 | end"#; 245 | let script = script.replace('\n', "\r\n"); 246 | let expected = expected.replace('\n', "\r\n"); 247 | 248 | let result = introduce_class(script.to_string(), marker, fallback_marker, class_def); 249 | 250 | assert_eq!(expected, result); 251 | } 252 | 253 | #[test] 254 | fn test_introduce_class_at_end() { 255 | let script = r#" 256 | hello 257 | end"#; 258 | let marker = "missing"; 259 | let fallback_marker = "also missing"; 260 | let class_def = "Class Foo\r\nEnd Class\r\n"; 261 | let expected = r#" 262 | hello 263 | end 264 | Class Foo 265 | End Class 266 | "#; 267 | let script = script.replace('\n', "\r\n"); 268 | let expected = expected.replace('\n', "\r\n"); 269 | 270 | let result = introduce_class(script.to_string(), marker, fallback_marker, class_def); 271 | 272 | assert_eq!(expected, result); 273 | } 274 | 275 | #[test] 276 | fn test_vbs_patch() { 277 | let script = r#" 278 | 'Define a variable for each drop target 279 | Dim DT9, DT47, DTA1v, DTJKv 280 | 281 | DTBk9=Array(sw9, sw9a, sw9p, 9, 0, true) 282 | DT47 = Array(sw47, sw47a, sw47p, 47, 0) 283 | DTA1v = Array(DTA1, DTA1a, DTA1p, 1, 0) 284 | DTJKv = Array(DTJK, DTJKa, DTJKp, 3, 0) 285 | 286 | Dim DTArray 287 | DTArray = Array(DTBk9,DT47,DTA1v,DTJKv) 288 | 289 | Sub DoDTAnim() 290 | Dim i 291 | For i=0 to Ubound(DTArray) 292 | DTArray(i)(4) = DTAnimate(DTArray(i)(0),DTArray(i)(1),DTArray(i)(2),DTArray(i)(3),DTArray(i)(4)) 293 | Next 294 | End Sub 295 | 296 | 'Define a variable for each stand-up target 297 | Dim ST41, ST42 298 | 299 | ST41= Array(sw41, Target_Rect_Fat_011_BM_Lit_Room, 41, 0) 300 | ST42 = Array(sw42, Target_Rect_Fat_010_BM_Lit_Room, 42, 0) 301 | 302 | Dim STArray 303 | STArray = Array(ST41,ST42) 304 | 305 | Sub DoSTAnim() 306 | Dim i 307 | For i=0 to Ubound(STArray) 308 | STArray(i)(3) = STAnimate(STArray(i)(0),STArray(i)(1),STArray(i)(2),STArray(i)(3)) 309 | Next 310 | End Sub 311 | "#; 312 | // vbs files should have windows line endings 313 | let script = script.replace('\n', "\r\n"); 314 | 315 | let expected = r#" 316 | Class DropTarget 317 | Private m_primary, m_secondary, m_prim, m_sw, m_animate, m_isDropped 318 | 319 | Public Property Get Primary(): Set Primary = m_primary: End Property 320 | Public Property Let Primary(input): Set m_primary = input: End Property 321 | 322 | Public Property Get Secondary(): Set Secondary = m_secondary: End Property 323 | Public Property Let Secondary(input): Set m_secondary = input: End Property 324 | 325 | Public Property Get Prim(): Set Prim = m_prim: End Property 326 | Public Property Let Prim(input): Set m_prim = input: End Property 327 | 328 | Public Property Get Sw(): Sw = m_sw: End Property 329 | Public Property Let Sw(input): m_sw = input: End Property 330 | 331 | Public Property Get Animate(): Animate = m_animate: End Property 332 | Public Property Let Animate(input): m_animate = input: End Property 333 | 334 | Public Property Get IsDropped(): IsDropped = m_isDropped: End Property 335 | Public Property Let IsDropped(input): m_isDropped = input: End Property 336 | 337 | Public default Function init(primary, secondary, prim, sw, animate, isDropped) 338 | Set m_primary = primary 339 | Set m_secondary = secondary 340 | Set m_prim = prim 341 | m_sw = sw 342 | m_animate = animate 343 | m_isDropped = isDropped 344 | 345 | Set Init = Me 346 | End Function 347 | End Class 348 | 349 | 'Define a variable for each drop target 350 | Dim DT9, DT47, DTA1v, DTJKv 351 | 352 | Set DTBk9=(new DropTarget)(sw9, sw9a, sw9p, 9, 0, true) 353 | Set DT47 = (new DropTarget)(sw47, sw47a, sw47p, 47, 0, false) 354 | Set DTA1v = (new DropTarget)(DTA1, DTA1a, DTA1p, 1, 0, false) 355 | Set DTJKv = (new DropTarget)(DTJK, DTJKa, DTJKp, 3, 0, false) 356 | 357 | Dim DTArray 358 | DTArray = Array(DTBk9,DT47,DTA1v,DTJKv) 359 | 360 | Sub DoDTAnim() 361 | Dim i 362 | For i=0 to Ubound(DTArray) 363 | DTArray(i).animate = DTAnimate(DTArray(i).primary,DTArray(i).secondary,DTArray(i).prim,DTArray(i).sw,DTArray(i).animate) 364 | Next 365 | End Sub 366 | 367 | Class StandupTarget 368 | Private m_primary, m_prim, m_sw, m_animate 369 | 370 | Public Property Get Primary(): Set Primary = m_primary: End Property 371 | Public Property Let Primary(input): Set m_primary = input: End Property 372 | 373 | Public Property Get Prim(): Set Prim = m_prim: End Property 374 | Public Property Let Prim(input): Set m_prim = input: End Property 375 | 376 | Public Property Get Sw(): Sw = m_sw: End Property 377 | Public Property Let Sw(input): m_sw = input: End Property 378 | 379 | Public Property Get Animate(): Animate = m_animate: End Property 380 | Public Property Let Animate(input): m_animate = input: End Property 381 | 382 | Public default Function init(primary, prim, sw, animate) 383 | Set m_primary = primary 384 | Set m_prim = prim 385 | m_sw = sw 386 | m_animate = animate 387 | 388 | Set Init = Me 389 | End Function 390 | End Class 391 | 392 | 'Define a variable for each stand-up target 393 | Dim ST41, ST42 394 | 395 | Set ST41= (new StandupTarget)(sw41, Target_Rect_Fat_011_BM_Lit_Room, 41, 0) 396 | Set ST42 = (new StandupTarget)(sw42, Target_Rect_Fat_010_BM_Lit_Room, 42, 0) 397 | 398 | Dim STArray 399 | STArray = Array(ST41,ST42) 400 | 401 | Sub DoSTAnim() 402 | Dim i 403 | For i=0 to Ubound(STArray) 404 | STArray(i).animate = STAnimate(STArray(i).primary,STArray(i).prim,STArray(i).sw,STArray(i).animate) 405 | Next 406 | End Sub 407 | "#; 408 | // vbs files should have windows line endings 409 | let expected = expected.replace('\n', "\r\n"); 410 | 411 | let (result, applied) = patch_script(script.to_string()); 412 | 413 | assert_eq!( 414 | applied, 415 | HashSet::from([PatchType::DropTarget, PatchType::StandupTarget]) 416 | ); 417 | assert_eq!(expected, result); 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/backglass.rs: -------------------------------------------------------------------------------- 1 | use image::{DynamicImage, RgbaImage}; 2 | use std::io; 3 | 4 | #[derive(Debug, PartialEq, Copy, Clone)] 5 | pub(crate) struct Vec2 { 6 | pub(crate) x: u32, 7 | pub(crate) y: u32, 8 | } 9 | 10 | impl Vec2 { 11 | pub fn new(x: u32, y: u32) -> Vec2 { 12 | Vec2 { x, y } 13 | } 14 | } 15 | 16 | /// relative location of the hole in the image 17 | #[derive(Debug, PartialEq)] 18 | pub(crate) struct DMDHole { 19 | pub(crate) pos: Vec2, 20 | pub(crate) dim: Vec2, 21 | pub(crate) parent_dim: Vec2, 22 | } 23 | 24 | impl DMDHole { 25 | pub(crate) fn new( 26 | x1: u32, 27 | y1: u32, 28 | x2: u32, 29 | y2: u32, 30 | parent_width: u32, 31 | parent_height: u32, 32 | ) -> DMDHole { 33 | DMDHole { 34 | pos: Vec2 { x: x1, y: y1 }, 35 | dim: Vec2 { 36 | x: x2 - x1 + 1, 37 | y: y2 - y1 + 1, 38 | }, 39 | parent_dim: Vec2 { 40 | x: parent_width, 41 | y: parent_height, 42 | }, 43 | } 44 | } 45 | 46 | pub fn width(&self) -> u32 { 47 | self.dim.x 48 | } 49 | 50 | pub fn height(&self) -> u32 { 51 | self.dim.y 52 | } 53 | 54 | pub fn x(&self) -> u32 { 55 | self.pos.x 56 | } 57 | 58 | pub fn y(&self) -> u32 { 59 | self.pos.y 60 | } 61 | 62 | #[allow(dead_code)] 63 | pub fn parent_width(&self) -> u32 { 64 | self.parent_dim.x 65 | } 66 | 67 | #[allow(dead_code)] 68 | pub fn parent_height(&self) -> u32 { 69 | self.parent_dim.y 70 | } 71 | 72 | pub fn scale_to_parent(&self, width: u32, height: u32) -> DMDHole { 73 | let x = (self.pos.x as f32 / self.parent_dim.x as f32 * width as f32) as u32; 74 | let y = (self.pos.y as f32 / self.parent_dim.y as f32 * height as f32) as u32; 75 | let dim_x = (self.dim.x as f32 / self.parent_dim.x as f32 * width as f32) as u32; 76 | let dim_y = (self.dim.y as f32 / self.parent_dim.y as f32 * height as f32) as u32; 77 | DMDHole { 78 | pos: Vec2 { x, y }, 79 | dim: Vec2 { x: dim_x, y: dim_y }, 80 | parent_dim: Vec2 { 81 | x: width, 82 | y: height, 83 | }, 84 | } 85 | } 86 | } 87 | 88 | /// Finds the dmd hole in the image by using the following algorithm: 89 | /// 1. Split the image in divisions*divisions parts 90 | /// 2. For each part, calculate get the center of the part 91 | /// 3. For each part, get the color of the center of the part 92 | /// 4. For each part, trace a line from the center to the edge of the image in all four directions 93 | /// 5. When the color changes (with a threshold), we have found the borders of that part 94 | /// 6. Pick the largest hole we found (there might be duplicates) 95 | /// 7. Only return the hole if it is wider than min_width% of the image 96 | /// 97 | /// Returns the hole in the image if found 98 | /// 99 | /// # Arguments 100 | /// 101 | /// * `image` - The image to find the hole in 102 | /// * `divisions` - The number of divisions to split the image in 103 | /// * `min_width` - The minimum width of the hole as a percentage of the image width 104 | /// * `max_deviation_u8` - The maximum deviation in color to consider a color change 105 | /// 106 | pub(crate) fn find_hole( 107 | image: &DynamicImage, 108 | divisions: u8, 109 | min_width: u32, 110 | max_deviation_u8: u8, 111 | ) -> io::Result> { 112 | // TODO we could optimize this by skipping a part if is contained in the largest hole we found so far 113 | let image_width = image.width(); 114 | let image_height = image.height(); 115 | let mut max_hole: Option = None; 116 | for x in 0..divisions { 117 | for y in 0..divisions { 118 | let x1 = (x as f32 / divisions as f32) * image_width as f32; 119 | let y1 = (y as f32 / divisions as f32) * image_height as f32; 120 | let x2 = ((x + 1) as f32 / divisions as f32) * image_width as f32; 121 | let y2 = ((y + 1) as f32 / divisions as f32) * image_height as f32; 122 | let center_x = ((x1 + x2) / 2.0) as u32; 123 | let center_y = ((y1 + y2) / 2.0) as u32; 124 | 125 | let hole = find_hole_from(image, center_x, center_y, max_deviation_u8)?; 126 | 127 | if hole.width() > min_width { 128 | if let Some(old_max_hole) = &max_hole { 129 | if hole.width() * hole.height() > old_max_hole.width() * old_max_hole.height() { 130 | max_hole = Some(hole); 131 | } 132 | } else { 133 | max_hole = Some(hole); 134 | } 135 | } 136 | } 137 | } 138 | 139 | Ok(max_hole) 140 | } 141 | 142 | fn find_hole_from( 143 | image: &DynamicImage, 144 | center_x: u32, 145 | center_y: u32, 146 | max_deviation_u8: u8, 147 | ) -> io::Result { 148 | let center: Vec2 = Vec2 { 149 | x: center_x, 150 | y: center_y, 151 | }; 152 | let image_width = image.width(); 153 | let image_height = image.height(); 154 | let rgba_image = match image.as_rgba8() { 155 | Some(rgba_image) => rgba_image, 156 | None => { 157 | return Err(io::Error::new( 158 | io::ErrorKind::InvalidData, 159 | "Image is not in RGBA format", 160 | )); 161 | } 162 | }; 163 | let center_color = rgba_image.get_pixel(center_x, center_y); 164 | 165 | let mut left = center_x; 166 | while left > 0 { 167 | left -= 1; 168 | let color_x = rgba_image.get_pixel(left, center_y); 169 | if !color_within_deviation(center_color, color_x, max_deviation_u8) { 170 | left += 1; 171 | break; 172 | } 173 | } 174 | let mut right = center_x; 175 | while right < image_width - 1 { 176 | right += 1; 177 | let color_x = rgba_image.get_pixel(right, center_y); 178 | if !color_within_deviation(center_color, color_x, max_deviation_u8) { 179 | right -= 1; 180 | break; 181 | } 182 | } 183 | let mut top = center_y; 184 | while top > 0 { 185 | top -= 1; 186 | let color_y = rgba_image.get_pixel(center_x, top); 187 | if !color_within_deviation(center_color, color_y, max_deviation_u8) { 188 | top += 1; 189 | break; 190 | } 191 | } 192 | let mut bottom = center_y; 193 | while bottom < image_height - 1 { 194 | bottom += 1; 195 | let color_y = rgba_image.get_pixel(center_x, bottom); 196 | if !color_within_deviation(center_color, color_y, max_deviation_u8) { 197 | bottom -= 1; 198 | break; 199 | } 200 | } 201 | 202 | // Now we do an outward from the center toward the corners check to account for 203 | // shamfered/filleted corners and shrink the hole accordingly. 204 | 205 | let top_left = trace_line( 206 | rgba_image, 207 | center, 208 | Vec2::new(left, top), 209 | center_color, 210 | max_deviation_u8, 211 | ); 212 | let top_right = trace_line( 213 | rgba_image, 214 | center, 215 | Vec2::new(right, top), 216 | center_color, 217 | max_deviation_u8, 218 | ); 219 | let bottom_left = trace_line( 220 | rgba_image, 221 | center, 222 | Vec2::new(left, bottom), 223 | center_color, 224 | max_deviation_u8, 225 | ); 226 | 227 | let bottom_right = trace_line( 228 | rgba_image, 229 | center, 230 | Vec2::new(right, bottom), 231 | center_color, 232 | max_deviation_u8, 233 | ); 234 | 235 | let left = top_left.x.max(bottom_left.x); 236 | let right = top_right.x.min(bottom_right.x); 237 | let top = top_left.y.max(top_right.y); 238 | let bottom = bottom_left.y.min(bottom_right.y); 239 | 240 | let hole = DMDHole::new(left, top, right, bottom, image_width, image_height); 241 | Ok(hole) 242 | } 243 | 244 | fn trace_line( 245 | rgba_image: &RgbaImage, 246 | start: Vec2, 247 | end: Vec2, 248 | color: &image::Rgba, 249 | max_deviation_u8: u8, 250 | ) -> Vec2 { 251 | let mut current = end; 252 | for point in LinePixelIterator::new(start, end) { 253 | let current_color = rgba_image.get_pixel(point.x, point.y); 254 | if !color_within_deviation(current_color, color, max_deviation_u8) { 255 | break; 256 | } 257 | current = point; 258 | } 259 | current 260 | } 261 | 262 | struct LinePixelIterator { 263 | x0: i32, 264 | y0: i32, 265 | x1: i32, 266 | y1: i32, 267 | dx: i32, 268 | dy: i32, 269 | sx: i32, 270 | sy: i32, 271 | err: i32, 272 | done: bool, 273 | } 274 | 275 | impl LinePixelIterator { 276 | fn new(from: Vec2, to: Vec2) -> Self { 277 | let x0 = from.x as i32; 278 | let y0 = from.y as i32; 279 | let x1 = to.x as i32; 280 | let y1 = to.y as i32; 281 | let dx = (x1 - x0).abs(); 282 | let dy = (y1 - y0).abs(); 283 | let sx = if x0 < x1 { 1 } else { -1 }; 284 | let sy = if y0 < y1 { 1 } else { -1 }; 285 | let err = dx - dy; 286 | LinePixelIterator { 287 | x0, 288 | y0, 289 | x1, 290 | y1, 291 | dx, 292 | dy, 293 | sx, 294 | sy, 295 | err, 296 | done: false, 297 | } 298 | } 299 | } 300 | 301 | impl Iterator for LinePixelIterator { 302 | type Item = Vec2; 303 | 304 | fn next(&mut self) -> Option { 305 | if self.done { 306 | return None; 307 | } 308 | 309 | let point = Vec2 { 310 | x: self.x0 as u32, 311 | y: self.y0 as u32, 312 | }; 313 | 314 | if self.x0 == self.x1 && self.y0 == self.y1 { 315 | self.done = true; 316 | } else { 317 | let e2 = 2 * self.err; 318 | if e2 > -self.dy { 319 | self.err -= self.dy; 320 | self.x0 += self.sx; 321 | } 322 | if e2 < self.dx { 323 | self.err += self.dx; 324 | self.y0 += self.sy; 325 | } 326 | } 327 | 328 | Some(point) 329 | } 330 | } 331 | 332 | fn color_within_deviation(c1: &image::Rgba, c2: &image::Rgba, max_deviation: u8) -> bool { 333 | let diff = |a: u8, b: u8| a.abs_diff(b) as u32; 334 | let total_deviation: u32 = 335 | c1.0.iter() 336 | .zip(c2.0.iter()) 337 | .map(|(a, b)| diff(*a, *b)) 338 | .sum(); 339 | total_deviation <= max_deviation as u32 * 4 340 | } 341 | 342 | #[cfg(test)] 343 | mod tests { 344 | use super::*; 345 | use image::RgbaImage; 346 | use pretty_assertions::assert_eq; 347 | use rand::Rng; 348 | 349 | #[test] 350 | fn test_find_hole_from() { 351 | let image_width = 20; 352 | let image_height = 16; 353 | let mut image = noise_image(image_width, image_height); 354 | clear_square( 355 | &mut image, 356 | 5, 357 | 4, 358 | 10, 359 | 8, 360 | image::Rgba([0xFF, 0xAA, 0x22, 255]), 361 | ); 362 | let dynamic_image = DynamicImage::ImageRgba8(image); 363 | 364 | let hole = find_hole_from(&dynamic_image, image_width / 2, image_height / 2, 0).unwrap(); 365 | let expected = DMDHole::new(5, 4, 14, 11, image_width, image_height); 366 | assert_eq!(hole, expected); 367 | } 368 | 369 | #[test] 370 | fn test_find_hole_from_with_inward_corners() { 371 | // we create an image with a cross like hole to force the algorithm to find the inward corners 372 | let image_width = 100; 373 | let image_height = 100; 374 | let mut image = noise_image(image_width, image_height); 375 | let black = image::Rgba([0x00, 0x00, 0x00, 255]); 376 | clear_square(&mut image, 10, 20, 80, 60, black); 377 | clear_square(&mut image, 20, 10, 60, 80, black); 378 | let dynamic_image = DynamicImage::ImageRgba8(image); 379 | 380 | // write image to disk 381 | //dynamic_image.save("test_find_hole_from.png").unwrap(); 382 | 383 | let hole = find_hole_from(&dynamic_image, image_width / 2, image_height / 2, 0).unwrap(); 384 | assert_eq!(hole.width(), 60); 385 | assert_eq!(hole.height(), 60); 386 | assert_eq!(hole.x(), 20); 387 | assert_eq!(hole.y(), 20); 388 | } 389 | 390 | #[test] 391 | fn test_find_hole() { 392 | let image_width = 320; 393 | let image_height = 200; 394 | let mut image = noise_image(image_width, image_height); 395 | clear_square( 396 | &mut image, 397 | 100, 398 | 50, 399 | 100, 400 | 50, 401 | image::Rgba([0xFF, 0xAA, 0x22, 255]), 402 | ); 403 | let dynamic_image = DynamicImage::ImageRgba8(image); 404 | 405 | let hole = find_hole(&dynamic_image, 10, 50, 1).unwrap(); 406 | let expected = Some(DMDHole::new(100, 50, 199, 99, image_width, image_height)); 407 | assert_eq!(hole, expected); 408 | } 409 | 410 | #[test] 411 | fn test_find_hole_no_hole() { 412 | let width = 320; 413 | let height = 200; 414 | let image = noise_image(width, height); 415 | let dynamic_image = DynamicImage::ImageRgba8(image); 416 | 417 | let hole = find_hole(&dynamic_image, 10, 10, 1).unwrap(); 418 | assert_eq!(hole, None); 419 | } 420 | 421 | #[test] 422 | fn test_find_whole_image_with_deviation_max() { 423 | let image_width = 320; 424 | let image_height = 200; 425 | let image = noise_image(image_width, image_height); 426 | let dynamic_image = DynamicImage::ImageRgba8(image); 427 | 428 | let hole = find_hole(&dynamic_image, 10, 100, 255).unwrap(); 429 | let expected = Some(DMDHole::new( 430 | 0, 431 | 0, 432 | image_width - 1, 433 | image_height - 1, 434 | image_width, 435 | image_height, 436 | )); 437 | assert_eq!(hole, expected); 438 | } 439 | 440 | #[test] 441 | fn test_dmd_hole_1_x_1() { 442 | let hole = DMDHole::new(0, 0, 0, 0, 1, 1); 443 | assert_eq!(hole.width(), 1); 444 | assert_eq!(hole.height(), 1); 445 | assert_eq!(hole.x(), 0); 446 | assert_eq!(hole.y(), 0); 447 | } 448 | 449 | #[test] 450 | fn test_dmd_hole_scale_1_x_1_to_parent() { 451 | let hole = DMDHole::new(0, 0, 1, 1, 2, 2); 452 | let scaled_hole = hole.scale_to_parent(4, 4); 453 | assert_eq!(scaled_hole.width(), 4); 454 | assert_eq!(scaled_hole.height(), 4); 455 | assert_eq!(scaled_hole.x(), 0); 456 | assert_eq!(scaled_hole.y(), 0); 457 | } 458 | 459 | #[test] 460 | fn test_dmd_hole_scale_to_parent() { 461 | let hole = DMDHole::new(8, 8, 21, 21, 30, 30); 462 | let scaled_hole = hole.scale_to_parent(20, 20); 463 | assert_eq!(scaled_hole.width(), 9); 464 | assert_eq!(scaled_hole.height(), 9); 465 | assert_eq!(scaled_hole.x(), 5); 466 | assert_eq!(scaled_hole.y(), 5); 467 | assert_eq!(scaled_hole.parent_width(), 20); 468 | assert_eq!(scaled_hole.parent_height(), 20); 469 | } 470 | 471 | fn noise_image(width: u32, height: u32) -> RgbaImage { 472 | let dynamic_image = DynamicImage::new_rgba8(width, height); 473 | let mut image = dynamic_image.to_rgba8(); 474 | let mut rng = rand::rng(); 475 | for x in 0..width { 476 | for y in 0..height { 477 | let random_color = image::Rgba([rng.random(), rng.random(), rng.random(), 255]); 478 | image.put_pixel(x, y, random_color); 479 | } 480 | } 481 | image 482 | } 483 | 484 | fn clear_square( 485 | image: &mut RgbaImage, 486 | x1: u32, 487 | y1: u32, 488 | width: u32, 489 | height: u32, 490 | color: image::Rgba, 491 | ) { 492 | for x in x1..x1 + width { 493 | for y in y1..y1 + height { 494 | image.put_pixel(x, y, color); 495 | } 496 | } 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use crate::vpinball_config::VPinballConfig; 4 | use dialoguer::Select; 5 | use dialoguer::theme::ColorfulTheme; 6 | use figment::{ 7 | Figment, 8 | providers::{Format, Toml}, 9 | }; 10 | use serde::{Deserialize, Serialize}; 11 | use std::collections::HashMap; 12 | use std::fs::File; 13 | use std::io::Write; 14 | use std::{env, io}; 15 | 16 | const CONFIGURATION_FILE_NAME: &str = "vpxtool.cfg"; 17 | 18 | #[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Eq)] 19 | pub struct LaunchTemplate { 20 | pub name: String, 21 | pub executable: PathBuf, 22 | pub arguments: Option>, 23 | pub env: Option>, 24 | } 25 | 26 | #[derive(Deserialize, Serialize)] 27 | pub struct Config { 28 | pub vpx_executable: PathBuf, 29 | pub vpx_config: Option, 30 | pub tables_folder: Option, 31 | pub editor: Option, 32 | pub launch_templates: Option>, 33 | } 34 | 35 | #[derive(PartialEq, Debug, Clone)] 36 | pub struct ResolvedConfig { 37 | pub vpx_executable: PathBuf, 38 | pub launch_templates: Vec, 39 | pub vpx_config: PathBuf, 40 | pub tables_folder: PathBuf, 41 | pub tables_index_path: PathBuf, 42 | pub editor: Option, 43 | } 44 | 45 | impl ResolvedConfig { 46 | pub fn global_pinmame_folder(&self) -> PathBuf { 47 | if cfg!(target_os = "windows") { 48 | self.vpx_executable.parent().unwrap().join("VPinMAME") 49 | } else { 50 | dirs::home_dir().unwrap().join(".pinmame") 51 | } 52 | } 53 | 54 | /// This path can be absolute or relative. 55 | /// In case it is relative, it will need to be resolved relative to the table vpx file. 56 | pub fn configured_pinmame_folder(&self) -> Option { 57 | // first we try to read the ini file 58 | if self.vpx_config.exists() { 59 | let vpinball_config = VPinballConfig::read(&self.vpx_config).unwrap(); 60 | if let Some(value) = vpinball_config.get_pinmame_path() { 61 | if value.trim().is_empty() { 62 | return None; 63 | } 64 | let path = PathBuf::from(value); 65 | return Some(path); 66 | } 67 | } 68 | None 69 | } 70 | } 71 | 72 | pub fn config_path() -> Option { 73 | let home_directory_configuration_path = home_config_path(); 74 | if home_directory_configuration_path.exists() { 75 | return Some(home_directory_configuration_path); 76 | } 77 | let local_configuration_path = local_config_path(); 78 | if local_configuration_path.exists() { 79 | return Some(local_configuration_path); 80 | } 81 | None 82 | } 83 | 84 | pub enum SetupConfigResult { 85 | Configured(PathBuf), 86 | Existing(PathBuf), 87 | } 88 | 89 | /// Setup the config file if it doesn't exist 90 | /// 91 | /// This might require user input! 92 | pub fn setup_config() -> io::Result { 93 | // TODO check if the config file already exists 94 | let existing_config_path = config_path(); 95 | match existing_config_path { 96 | Some(path) => Ok(SetupConfigResult::Existing(path)), 97 | None => { 98 | // TODO avoid stdout interaction here 99 | println!("Warning: Failed find a config file."); 100 | let new_config = create_default_config()?; 101 | Ok(SetupConfigResult::Configured(new_config.0)) 102 | } 103 | } 104 | } 105 | 106 | /// Load the config file if it exists, otherwise create a new one 107 | /// 108 | /// This might require user input! 109 | pub fn load_or_setup_config() -> io::Result<(PathBuf, ResolvedConfig)> { 110 | match load_config()? { 111 | Some(loaded) => Ok(loaded), 112 | None => { 113 | // TODO avoid stdout interaction here 114 | println!("Warning: Failed find a config file."); 115 | create_default_config() 116 | } 117 | } 118 | } 119 | 120 | pub fn load_config() -> io::Result> { 121 | match config_path() { 122 | Some(config_path) => { 123 | let config = read_config(&config_path)?; 124 | Ok(Some((config_path, config))) 125 | } 126 | None => Ok(None), 127 | } 128 | } 129 | 130 | fn read_config(config_path: &Path) -> io::Result { 131 | let figment = Figment::new().merge(Toml::file(config_path)); 132 | let config: Config = figment.extract().map_err(|e| { 133 | io::Error::new( 134 | io::ErrorKind::InvalidData, 135 | format!("Failed to load config file: {e}"), 136 | ) 137 | })?; 138 | // apply defaults 139 | // TODO we might want to suggest the value in the config file by having it empty with a comment 140 | let tables_folder = config 141 | .tables_folder 142 | .unwrap_or(default_tables_root(&config.vpx_executable)); 143 | let vpx_config = config 144 | .vpx_config 145 | .unwrap_or_else(|| default_vpinball_ini_file(&config.vpx_executable)); 146 | 147 | // generate launch templates if not set 148 | let launch_templates = config.launch_templates.unwrap_or_else(|| { 149 | // normal, force fullscreen, force windowed 150 | generate_default_launch_templates(&config.vpx_executable) 151 | }); 152 | 153 | let resolved_config = ResolvedConfig { 154 | vpx_executable: config.vpx_executable, 155 | launch_templates, 156 | vpx_config, 157 | tables_folder: tables_folder.clone(), 158 | tables_index_path: tables_index_path(&tables_folder), 159 | editor: config.editor, 160 | }; 161 | Ok(resolved_config) 162 | } 163 | 164 | fn generate_default_launch_templates(vpx_executable: &Path) -> Vec { 165 | let default_env = HashMap::from([ 166 | ("SDL_VIDEODRIVER".to_string(), "".to_string()), 167 | ("SDL_RENDER_DRIVER".to_string(), "".to_string()), 168 | ]); 169 | 170 | vec![ 171 | LaunchTemplate { 172 | name: "Launch".to_string(), 173 | executable: vpx_executable.to_owned(), 174 | arguments: None, 175 | env: Some(default_env.clone()), 176 | }, 177 | LaunchTemplate { 178 | name: "Launch Fullscreen".to_string(), 179 | executable: vpx_executable.to_owned(), 180 | arguments: Some(vec!["-EnableTrueFullscreen".to_string()]), 181 | env: None, 182 | }, 183 | LaunchTemplate { 184 | name: "Launch Windowed".to_string(), 185 | executable: vpx_executable.to_owned(), 186 | arguments: Some(vec!["-DisableTrueFullscreen".to_string()]), 187 | env: None, 188 | }, 189 | ] 190 | } 191 | 192 | pub fn tables_index_path(tables_folder: &Path) -> PathBuf { 193 | tables_folder.join("vpxtool_index.json") 194 | } 195 | 196 | pub fn clear_config() -> io::Result> { 197 | let config_path = config_path(); 198 | match config_path { 199 | Some(path) => { 200 | std::fs::remove_file(&path)?; 201 | Ok(Some(path)) 202 | } 203 | None => Ok(None), 204 | } 205 | } 206 | 207 | fn local_config_path() -> PathBuf { 208 | Path::new(CONFIGURATION_FILE_NAME).to_path_buf() 209 | } 210 | 211 | fn home_config_path() -> PathBuf { 212 | dirs::config_dir().unwrap().join(CONFIGURATION_FILE_NAME) 213 | } 214 | 215 | fn default_vpinball_ini_file(vpx_executable_path: &Path) -> PathBuf { 216 | if cfg!(target_os = "windows") { 217 | // in the same directory as the vpx executable 218 | vpx_executable_path.parent().unwrap().join("VPinballX.ini") 219 | } else { 220 | // batocera has a specific location for the ini file 221 | let batocera_path = PathBuf::from("/userdata/system/configs/vpinball/VPinballX.ini"); 222 | if batocera_path.exists() { 223 | return batocera_path; 224 | } 225 | 226 | // default vpinball ini file location is ~/.vpinball/VPinballX.ini 227 | dirs::home_dir() 228 | .unwrap() 229 | .join(".vpinball") 230 | .join("VPinballX.ini") 231 | } 232 | } 233 | 234 | /// Create a default config file 235 | /// 236 | /// This requires user input! 237 | fn create_default_config() -> io::Result<(PathBuf, ResolvedConfig)> { 238 | let local_configuration_path = local_config_path(); 239 | let home_directory_configuration_path = home_config_path(); 240 | let choices: Vec<(&str, String)> = vec![ 241 | ( 242 | "Home", 243 | home_directory_configuration_path 244 | .to_string_lossy() 245 | .to_string(), 246 | ), 247 | ( 248 | "Local", 249 | local_configuration_path.to_string_lossy().to_string(), 250 | ), 251 | ]; 252 | 253 | let selection_opt = Select::with_theme(&ColorfulTheme::default()) 254 | .with_prompt("Choose a configuration location:") 255 | .default(0) 256 | .items( 257 | choices 258 | .iter() 259 | .map(|(choice, description)| format!("{choice} \x1b[90m{description}\x1b[0m")) 260 | .collect::>(), 261 | ) 262 | .interact_opt()?; 263 | 264 | let config_file = if let Some(index) = selection_opt { 265 | let (_selected_choice, path) = (&choices[index].0, &choices[index].1); 266 | PathBuf::from(path) 267 | } else { 268 | unreachable!("Failed to select a configuration file path."); 269 | }; 270 | 271 | let mut vpx_executable = default_vpinball_executable(); 272 | 273 | if !vpx_executable.exists() { 274 | println!("Warning: Failed to detect the vpinball executable."); 275 | print!("vpinball executable path: "); 276 | io::stdout().flush().expect("Failed to flush stdout"); 277 | 278 | let mut new_executable_path = String::new(); 279 | io::stdin() 280 | .read_line(&mut new_executable_path) 281 | .expect("Failed to read line"); 282 | 283 | vpx_executable = PathBuf::from(new_executable_path.trim().to_string()); 284 | 285 | if !vpx_executable.exists() { 286 | println!("Error: input file path wasn't found."); 287 | println!("Executable path is not set. "); 288 | std::process::exit(1); 289 | } 290 | } 291 | 292 | write_default_config(&config_file, &vpx_executable)?; 293 | 294 | let resolved_config = read_config(&config_file)?; 295 | Ok((config_file, resolved_config)) 296 | } 297 | 298 | fn write_default_config(config_file: &Path, vpx_executable: &Path) -> io::Result<()> { 299 | let launch_templates = generate_default_launch_templates(vpx_executable); 300 | 301 | let vpx_config = default_vpinball_ini_file(vpx_executable); 302 | let tables_folder = default_tables_root(vpx_executable); 303 | let config = Config { 304 | vpx_executable: vpx_executable.to_owned(), 305 | launch_templates: Some(launch_templates), 306 | vpx_config: Some(vpx_config.clone()), 307 | tables_folder: Some(tables_folder.clone()), 308 | editor: None, 309 | }; 310 | write_config(config_file, &config)?; 311 | Ok(()) 312 | } 313 | 314 | fn write_config(config_file: &Path, config: &Config) -> io::Result<()> { 315 | let toml = toml::to_string(&config).unwrap(); 316 | let mut file = File::create(config_file)?; 317 | file.write_all(toml.as_bytes()) 318 | } 319 | 320 | pub fn default_tables_root(vpx_executable: &Path) -> PathBuf { 321 | // when on macos we assume that the tables are in ~/.vpinball/tables 322 | if cfg!(target_os = "macos") { 323 | dirs::home_dir().unwrap().join(".vpinball").join("tables") 324 | } else { 325 | vpx_executable.parent().unwrap().join("tables") 326 | } 327 | } 328 | 329 | fn default_vpinball_executable() -> PathBuf { 330 | if cfg!(target_os = "windows") { 331 | // baller installer default 332 | let dir = PathBuf::from("c:\\vPinball\\VisualPinball"); 333 | let exe = dir.join("VPinballX64.exe"); 334 | 335 | // Check current directory 336 | let local = env::current_dir().unwrap(); 337 | if local.join("VPinballX64.exe").exists() { 338 | local.join("VPinballX64.exe") 339 | } else if local.join("VPinballX.exe").exists() { 340 | local.join("VPinballX.exe") 341 | } else if exe.exists() { 342 | exe 343 | } else { 344 | dir.join("VPinballX.exe") 345 | } 346 | } else if cfg!(target_os = "macos") { 347 | let dmg_install = 348 | PathBuf::from("/Applications/VPinballX_GL.app/Contents/MacOS/VPinballX_GL"); 349 | if dmg_install.exists() { 350 | dmg_install 351 | } else { 352 | let mut local = env::current_dir().unwrap(); 353 | local = local.join("VPinballX_GL"); 354 | local 355 | } 356 | } else { 357 | let mut local = env::current_dir().unwrap(); 358 | local = local.join("VPinballX_GL"); 359 | 360 | if local.exists() { 361 | local 362 | } else { 363 | let home = dirs::home_dir().unwrap(); 364 | home.join("vpinball").join("vpinball").join("VPinballX_GL") 365 | } 366 | } 367 | } 368 | 369 | #[cfg(test)] 370 | mod tests { 371 | use super::*; 372 | use pretty_assertions::assert_eq; 373 | use testdir::testdir; 374 | 375 | #[cfg(target_os = "linux")] 376 | #[test] 377 | fn test_write_default_config_linux() -> io::Result<()> { 378 | use std::io::Read; 379 | let temp_dir = testdir!(); 380 | let config_file = temp_dir.join(CONFIGURATION_FILE_NAME); 381 | write_default_config(&config_file, &PathBuf::from("/home/me/vpinball"))?; 382 | // print the config file 383 | let mut file = File::open(&config_file)?; 384 | let mut contents = String::new(); 385 | file.read_to_string(&mut contents)?; 386 | println!("Config file contents: {contents}"); 387 | let config = read_config(&config_file)?; 388 | assert_eq!( 389 | config, 390 | ResolvedConfig { 391 | vpx_executable: PathBuf::from("/home/me/vpinball"), 392 | launch_templates: vec!( 393 | LaunchTemplate { 394 | name: "Launch".to_string(), 395 | executable: PathBuf::from("/home/me/vpinball"), 396 | arguments: None, 397 | env: Some(HashMap::from([ 398 | ("SDL_VIDEODRIVER".to_string(), "".to_string()), 399 | ("SDL_RENDER_DRIVER".to_string(), "".to_string()), 400 | ])), 401 | }, 402 | LaunchTemplate { 403 | name: "Launch Fullscreen".to_string(), 404 | executable: PathBuf::from("/home/me/vpinball"), 405 | arguments: Some(vec!["-EnableTrueFullscreen".to_string()]), 406 | env: None, 407 | }, 408 | LaunchTemplate { 409 | name: "Launch Windowed".to_string(), 410 | executable: PathBuf::from("/home/me/vpinball"), 411 | arguments: Some(vec!["-DisableTrueFullscreen".to_string()]), 412 | env: None, 413 | }, 414 | ), 415 | 416 | vpx_config: dirs::home_dir().unwrap().join(".vpinball/VPinballX.ini"), 417 | tables_folder: PathBuf::from("/home/me/tables"), 418 | tables_index_path: PathBuf::from("/home/me/tables/vpxtool_index.json"), 419 | editor: None, 420 | } 421 | ); 422 | Ok(()) 423 | } 424 | 425 | // test that we can read an incomplete config file with missing tables_folder 426 | #[cfg(target_os = "linux")] 427 | #[test] 428 | fn test_read_incomplete_config_linux() -> io::Result<()> { 429 | let temp_dir = testdir!(); 430 | let config_file = temp_dir.join(CONFIGURATION_FILE_NAME); 431 | let mut file = File::create(&config_file)?; 432 | file.write_all(b"vpx_executable = \"/tmp/test/vpinball\"")?; 433 | 434 | let config = read_config(&config_file)?; 435 | 436 | assert_eq!( 437 | config, 438 | ResolvedConfig { 439 | vpx_executable: PathBuf::from("/tmp/test/vpinball"), 440 | launch_templates: vec!( 441 | LaunchTemplate { 442 | name: "Launch".to_string(), 443 | executable: PathBuf::from("/tmp/test/vpinball"), 444 | arguments: None, 445 | env: Some(HashMap::from([ 446 | ("SDL_VIDEODRIVER".to_string(), "".to_string()), 447 | ("SDL_RENDER_DRIVER".to_string(), "".to_string()), 448 | ])), 449 | }, 450 | LaunchTemplate { 451 | name: "Launch Fullscreen".to_string(), 452 | executable: PathBuf::from("/tmp/test/vpinball"), 453 | arguments: Some(vec!["-EnableTrueFullscreen".to_string()]), 454 | env: None, 455 | }, 456 | LaunchTemplate { 457 | name: "Launch Windowed".to_string(), 458 | executable: PathBuf::from("/tmp/test/vpinball"), 459 | arguments: Some(vec!["-DisableTrueFullscreen".to_string()]), 460 | env: None, 461 | }, 462 | ), 463 | vpx_config: dirs::home_dir().unwrap().join(".vpinball/VPinballX.ini"), 464 | tables_folder: PathBuf::from("/tmp/test/tables"), 465 | tables_index_path: PathBuf::from("/tmp/test/tables/vpxtool_index.json"), 466 | editor: None, 467 | } 468 | ); 469 | Ok(()) 470 | } 471 | 472 | #[cfg(target_os = "macos")] 473 | #[test] 474 | fn test_read_incomplete_config_macos() -> io::Result<()> { 475 | let temp_dir = testdir!(); 476 | let config_file = temp_dir.join(CONFIGURATION_FILE_NAME); 477 | let mut file = File::create(&config_file)?; 478 | file.write_all(b"vpx_executable = \"/tmp/test/vpinball\"")?; 479 | 480 | let config = read_config(&config_file)?; 481 | 482 | let expected_tables_dir = dirs::home_dir().unwrap().join(".vpinball").join("tables"); 483 | assert_eq!( 484 | config, 485 | ResolvedConfig { 486 | vpx_executable: PathBuf::from("/tmp/test/vpinball"), 487 | launch_templates: vec!( 488 | LaunchTemplate { 489 | name: "Launch".to_string(), 490 | executable: PathBuf::from("/tmp/test/vpinball"), 491 | arguments: None, 492 | env: Some(HashMap::from([ 493 | ("SDL_VIDEODRIVER".to_string(), "".to_string()), 494 | ("SDL_RENDER_DRIVER".to_string(), "".to_string()), 495 | ])), 496 | }, 497 | LaunchTemplate { 498 | name: "Launch Fullscreen".to_string(), 499 | executable: PathBuf::from("/tmp/test/vpinball"), 500 | arguments: Some(vec!["-EnableTrueFullscreen".to_string()]), 501 | env: None, 502 | }, 503 | LaunchTemplate { 504 | name: "Launch Windowed".to_string(), 505 | executable: PathBuf::from("/tmp/test/vpinball"), 506 | arguments: Some(vec!["-DisableTrueFullscreen".to_string()]), 507 | env: None, 508 | } 509 | ), 510 | vpx_config: dirs::home_dir().unwrap().join(".vpinball/VPinballX.ini"), 511 | tables_folder: expected_tables_dir.clone(), 512 | tables_index_path: expected_tables_dir.join("vpxtool_index.json"), 513 | editor: None, 514 | } 515 | ); 516 | Ok(()) 517 | } 518 | 519 | #[cfg(target_os = "windows")] 520 | #[test] 521 | fn test_read_incomplete_config_windows() -> io::Result<()> { 522 | let temp_dir = testdir!(); 523 | let config_file = temp_dir.join(CONFIGURATION_FILE_NAME); 524 | let mut file = File::create(&config_file)?; 525 | file.write_all(b"vpx_executable = \"C:\\\\test\\\\vpinball\"")?; 526 | 527 | let config = read_config(&config_file)?; 528 | 529 | assert_eq!( 530 | config, 531 | ResolvedConfig { 532 | vpx_executable: PathBuf::from("C:\\test\\vpinball"), 533 | vpx_config: PathBuf::from("C:\\test\\VPinballX.ini"), 534 | tables_folder: PathBuf::from("C:\\test\\tables"), 535 | tables_index_path: PathBuf::from("C:\\test\\tables\\vpxtool_index.json"), 536 | editor: None, 537 | launch_templates: vec!( 538 | LaunchTemplate { 539 | name: "Launch".to_string(), 540 | executable: PathBuf::from("C:\\test\\vpinball"), 541 | arguments: None, 542 | env: Some(HashMap::from([ 543 | ("SDL_VIDEODRIVER".to_string(), "".to_string()), 544 | ("SDL_RENDER_DRIVER".to_string(), "".to_string()), 545 | ])), 546 | }, 547 | LaunchTemplate { 548 | name: "Launch Fullscreen".to_string(), 549 | executable: PathBuf::from("C:\\test\\vpinball"), 550 | arguments: Some(vec!["-EnableTrueFullscreen".to_string()]), 551 | env: None, 552 | }, 553 | LaunchTemplate { 554 | name: "Launch Windowed".to_string(), 555 | executable: PathBuf::from("C:\\test\\vpinball"), 556 | arguments: Some(vec!["-DisableTrueFullscreen".to_string()]), 557 | env: None, 558 | } 559 | ) 560 | } 561 | ); 562 | Ok(()) 563 | } 564 | } 565 | -------------------------------------------------------------------------------- /src/frontend.rs: -------------------------------------------------------------------------------- 1 | use crate::backglass::find_hole; 2 | use crate::cli::{ 3 | DiffColor, ProgressBarProgress, confirm, info_diff, info_edit, info_gather, open_editor, 4 | run_diff, script_diff, 5 | }; 6 | use crate::colorful_theme_patched::ColorfulThemePatched; 7 | use crate::config::{LaunchTemplate, ResolvedConfig}; 8 | use crate::indexer::{IndexError, IndexedTable, Progress}; 9 | use crate::patcher::LineEndingsResult::{NoChanges, Unified}; 10 | use crate::patcher::{patch_vbs_file, unify_line_endings_vbs_file}; 11 | use crate::vpinball_config::{VPinballConfig, WindowInfo, WindowType}; 12 | use crate::{indexer, strip_cr_lf}; 13 | use base64::Engine; 14 | use colored::Colorize; 15 | use console::{Emoji, Term}; 16 | use dialoguer::theme::ColorfulTheme; 17 | use dialoguer::{FuzzySelect, Input, MultiSelect, Select}; 18 | use indicatif::{ProgressBar, ProgressStyle}; 19 | use is_executable::IsExecutable; 20 | use pinmame_nvram::dips::{DipSwitchState, get_all_dip_switches, set_dip_switches}; 21 | use pinmame_nvram::{DipSwitchInfo, Nvram}; 22 | use std::fs::OpenOptions; 23 | use std::io::BufReader; 24 | use std::{ 25 | fs::File, 26 | io, 27 | io::Write, 28 | path::{Path, PathBuf}, 29 | process::{ExitStatus, exit}, 30 | }; 31 | use vpin::vpx::{ExtractResult, extractvbs, ini_path_for, vbs_path_for}; 32 | 33 | const LAUNCH: Emoji = Emoji("🚀", "[launch]"); 34 | const CRASH: Emoji = Emoji("💥", "[crash]"); 35 | 36 | const SEARCH: &str = "> Search"; 37 | const RECENT: &str = "> Recent"; 38 | const SEARCH_INDEX: usize = 0; 39 | const RECENT_INDEX: usize = 1; 40 | 41 | #[derive(PartialEq, Eq, Clone)] 42 | enum TableOption { 43 | Launch { template: LaunchTemplate }, 44 | ForceReload, 45 | InfoShow, 46 | InfoEdit, 47 | InfoDiff, 48 | ExtractVBS, 49 | EditVBS, 50 | PatchVBS, 51 | UnifyLineEndings, 52 | ShowVBSDiff, 53 | CreateVBSPatch, 54 | NVRAMDipSwitches, 55 | NVRAMShow, 56 | NVRAMClear, 57 | B2SAutoPositionDMD, 58 | EditTableINI, 59 | EditMainIni, 60 | } 61 | 62 | impl TableOption { 63 | fn all(config: &ResolvedConfig) -> Vec { 64 | let mut options: Vec = config 65 | .launch_templates 66 | .iter() 67 | .map(|t| TableOption::Launch { 68 | template: t.clone(), 69 | }) 70 | .collect(); 71 | 72 | options.extend(vec![ 73 | TableOption::ForceReload, 74 | TableOption::InfoShow, 75 | TableOption::InfoEdit, 76 | TableOption::InfoDiff, 77 | TableOption::ExtractVBS, 78 | TableOption::EditVBS, 79 | TableOption::PatchVBS, 80 | TableOption::UnifyLineEndings, 81 | TableOption::ShowVBSDiff, 82 | TableOption::CreateVBSPatch, 83 | TableOption::NVRAMDipSwitches, 84 | TableOption::NVRAMShow, 85 | TableOption::NVRAMClear, 86 | TableOption::B2SAutoPositionDMD, 87 | TableOption::EditTableINI, 88 | TableOption::EditMainIni, 89 | ]); 90 | options 91 | } 92 | 93 | fn display(&self) -> String { 94 | match self { 95 | TableOption::Launch { 96 | template: LaunchTemplate { name, .. }, 97 | } => name.clone(), 98 | TableOption::ForceReload => "Force reload".to_string(), 99 | TableOption::InfoShow => "Info > Show".to_string(), 100 | TableOption::InfoEdit => "Info > Edit".to_string(), 101 | TableOption::InfoDiff => "Info > Diff".to_string(), 102 | TableOption::ExtractVBS => "VBScript > Extract".to_string(), 103 | TableOption::EditVBS => "VBScript > Edit".to_string(), 104 | TableOption::PatchVBS => "VBScript > Patch typical standalone issues".to_string(), 105 | TableOption::UnifyLineEndings => "VBScript > Unify line endings".to_string(), 106 | TableOption::ShowVBSDiff => "VBScript > Diff".to_string(), 107 | TableOption::CreateVBSPatch => "VBScript > Create patch file".to_string(), 108 | TableOption::NVRAMDipSwitches => "NVRAM > DIP Switches".to_string(), 109 | TableOption::NVRAMShow => "NVRAM > Show".to_string(), 110 | TableOption::NVRAMClear => "NVRAM > Clear".to_string(), 111 | TableOption::B2SAutoPositionDMD => "Backglass > Auto-position DMD".to_string(), 112 | TableOption::EditTableINI => "INI > Edit table ini".to_string(), 113 | TableOption::EditMainIni => "INI > Edit main ini".to_string(), 114 | } 115 | } 116 | } 117 | 118 | pub fn frontend_index( 119 | resolved_config: &ResolvedConfig, 120 | recursive: bool, 121 | force_reindex: Vec, 122 | ) -> Result, IndexError> { 123 | let pb = ProgressBar::hidden(); 124 | pb.set_style( 125 | ProgressStyle::with_template( 126 | "{spinner:.green} [{bar:.cyan/blue}] {pos}/{human_len} ({eta})", 127 | ) 128 | .unwrap(), 129 | ); 130 | let progress = ProgressBarProgress::new(pb); 131 | let index = indexer::index_folder( 132 | recursive, 133 | &resolved_config.tables_folder, 134 | &resolved_config.tables_index_path, 135 | Some(&resolved_config.global_pinmame_folder()), 136 | resolved_config.configured_pinmame_folder().as_deref(), 137 | &progress, 138 | force_reindex, 139 | ); 140 | progress.finish_and_clear(); 141 | let index = index?; 142 | 143 | let mut tables: Vec = index.tables(); 144 | tables.sort_by_key(|indexed| display_table_line(indexed).to_lowercase()); 145 | Ok(tables) 146 | } 147 | 148 | pub fn frontend(config: &ResolvedConfig, mut vpx_files_with_tableinfo: Vec) { 149 | let mut main_selection_opt = None; 150 | loop { 151 | let tables: Vec = vpx_files_with_tableinfo 152 | .iter() 153 | .map(display_table_line_full) 154 | .collect(); 155 | 156 | let mut selections = vec![SEARCH.bold().to_string(), RECENT.bold().to_string()]; 157 | selections.extend(tables.clone()); 158 | 159 | if let Err(e) = Term::stderr().clear_screen() { 160 | eprintln!("Failed to clear screen: {e}"); 161 | } 162 | main_selection_opt = Select::with_theme(&ColorfulTheme::default()) 163 | .with_prompt("Select a table") 164 | .default(main_selection_opt.unwrap_or(0)) 165 | .items(&selections[..]) 166 | .interact_opt() 167 | .unwrap(); 168 | 169 | match main_selection_opt { 170 | Some(selection) => { 171 | // search 172 | 173 | match selection { 174 | SEARCH_INDEX => { 175 | // show a fuzzy search 176 | let selected = FuzzySelect::with_theme(&ColorfulThemePatched::default()) 177 | // highlight breaks existing formatting https://github.com/console-rs/dialoguer/issues/312 178 | .highlight_matches(false) 179 | .with_prompt("Search a table:") 180 | .items(&tables) 181 | .interact_opt() 182 | .unwrap(); 183 | 184 | if let Some(selected_index) = selected { 185 | let info = vpx_files_with_tableinfo 186 | .get(selected_index) 187 | .unwrap() 188 | .clone(); 189 | let info_str = display_table_line_full(&info); 190 | table_menu(config, &mut vpx_files_with_tableinfo, &info, &info_str); 191 | } 192 | } 193 | RECENT_INDEX => { 194 | // take the last 10 most recent tables 195 | let mut recent: Vec = vpx_files_with_tableinfo.clone(); 196 | recent.sort_by_key(|indexed| indexed.last_modified); 197 | let last_modified = recent.iter().rev().take(50).collect::>(); 198 | let last_modified_str: Vec = last_modified 199 | .iter() 200 | .map(|indexed| display_table_line_full(indexed)) 201 | .collect(); 202 | 203 | let selected = Select::with_theme(&ColorfulTheme::default()) 204 | .with_prompt("Select a table") 205 | .items(&last_modified_str) 206 | .default(0) 207 | .interact_opt() 208 | .unwrap(); 209 | 210 | if let Some(selected_index) = selected { 211 | let info = last_modified.get(selected_index).unwrap(); 212 | let info_str = display_table_line_full(info); 213 | table_menu(config, &mut vpx_files_with_tableinfo, info, &info_str); 214 | } 215 | } 216 | _ => { 217 | let index = selection - 2; 218 | 219 | let info = vpx_files_with_tableinfo.get(index).unwrap().clone(); 220 | let info_str = display_table_line_full(&info); 221 | table_menu(config, &mut vpx_files_with_tableinfo, &info, &info_str); 222 | } 223 | } 224 | } 225 | None => break, 226 | }; 227 | } 228 | } 229 | 230 | fn table_menu( 231 | config: &ResolvedConfig, 232 | vpx_files_with_tableinfo: &mut Vec, 233 | info: &IndexedTable, 234 | info_str: &str, 235 | ) { 236 | let selected_path = &info.path; 237 | let mut exit = false; 238 | let mut option = None; 239 | while !exit { 240 | option = choose_table_option(config, info_str, option); 241 | match option { 242 | Some(TableOption::Launch { ref template }) => { 243 | launch(selected_path, template); 244 | exit = true; 245 | } 246 | Some(TableOption::ForceReload) => { 247 | match frontend_index(config, true, vec![selected_path.clone()]) { 248 | Ok(index) => { 249 | vpx_files_with_tableinfo.clear(); 250 | vpx_files_with_tableinfo.extend(index); 251 | // exit to not have to 252 | // * check if the table is still in the list 253 | // * check if the info_str has changed 254 | exit = true; 255 | } 256 | Err(err) => { 257 | let msg = format!("Unable to reload tables: {err:?}"); 258 | prompt(&msg.truecolor(255, 125, 0).to_string()); 259 | } 260 | } 261 | } 262 | Some(TableOption::EditVBS) => { 263 | let path = vbs_path_for(selected_path); 264 | let result = if path.exists() { 265 | open_editor(&path, Some(config)) 266 | } else { 267 | extractvbs(selected_path, None, false) 268 | .and_then(|_| open_editor(&path, Some(config))) 269 | }; 270 | report_launch_result(&path, result); 271 | } 272 | Some(TableOption::ExtractVBS) => match extractvbs(selected_path, None, false) { 273 | Ok(ExtractResult::Extracted(path)) => { 274 | prompt(&format!("VBS extracted to {}", path.to_string_lossy())); 275 | } 276 | Ok(ExtractResult::Existed(path)) => { 277 | let msg = format!("VBS already exists at {}", path.to_string_lossy()); 278 | prompt(&msg.truecolor(255, 125, 0).to_string()); 279 | } 280 | Err(err) => { 281 | let msg = format!("Unable to extract VBS: {err}"); 282 | prompt(&msg.truecolor(255, 125, 0).to_string()); 283 | } 284 | }, 285 | Some(TableOption::ShowVBSDiff) => match script_diff(selected_path) { 286 | Ok(diff) => { 287 | prompt(&diff); 288 | } 289 | Err(err) => { 290 | let msg = format!("Unable to diff VBS: {err}"); 291 | prompt(&msg.truecolor(255, 125, 0).to_string()); 292 | } 293 | }, 294 | Some(TableOption::PatchVBS) => { 295 | let vbs_path = match extractvbs(selected_path, None, false) { 296 | Ok(ExtractResult::Existed(path)) => path, 297 | Ok(ExtractResult::Extracted(path)) => path, 298 | Err(err) => { 299 | let msg = format!("Unable to extract VBS: {err}"); 300 | prompt(&msg.truecolor(255, 125, 0).to_string()); 301 | return; 302 | } 303 | }; 304 | match patch_vbs_file(&vbs_path) { 305 | Ok(applied) => { 306 | if applied.is_empty() { 307 | prompt("No patches applied."); 308 | } else { 309 | applied.iter().for_each(|patch| { 310 | println!("Applied patch: {patch}"); 311 | }); 312 | prompt(&format!( 313 | "Patched VBS file at {}", 314 | vbs_path.to_string_lossy() 315 | )); 316 | } 317 | } 318 | Err(err) => { 319 | let msg = format!("Unable to patch VBS: {err}"); 320 | prompt(&msg.truecolor(255, 125, 0).to_string()); 321 | } 322 | } 323 | } 324 | Some(TableOption::UnifyLineEndings) => { 325 | let vbs_path = vbs_path_for(selected_path); 326 | let vbs_path = match extractvbs(selected_path, Some(vbs_path), false) { 327 | Ok(ExtractResult::Existed(path)) => path, 328 | Ok(ExtractResult::Extracted(path)) => path, 329 | Err(err) => { 330 | let msg = format!("Unable to extract VBS: {err}"); 331 | prompt(&msg.truecolor(255, 125, 0).to_string()); 332 | return; 333 | } 334 | }; 335 | match unify_line_endings_vbs_file(&vbs_path) { 336 | Ok(NoChanges) => { 337 | prompt("No changes applied as file has correct line endings"); 338 | } 339 | Ok(Unified) => { 340 | prompt(&format!( 341 | "Unified line endings in VBS file at {}", 342 | vbs_path.to_string_lossy() 343 | )); 344 | } 345 | Err(err) => { 346 | let msg = format!("Unable to patch VBS: {err}"); 347 | prompt(&msg.truecolor(255, 125, 0).to_string()); 348 | } 349 | } 350 | } 351 | Some(TableOption::CreateVBSPatch) => { 352 | let vbs_path = selected_path.with_extension("vbs.original"); 353 | let original_path = match extractvbs(selected_path, Some(vbs_path), true) { 354 | Ok(ExtractResult::Existed(path)) => path, 355 | Ok(ExtractResult::Extracted(path)) => path, 356 | Err(err) => { 357 | let msg = format!("Unable to extract VBS: {err}"); 358 | prompt(&msg.truecolor(255, 125, 0).to_string()); 359 | return; 360 | } 361 | }; 362 | let vbs_path = vbs_path_for(selected_path); 363 | let patch_path = vbs_path.with_extension("vbs.patch"); 364 | 365 | match run_diff(&original_path, &vbs_path, DiffColor::Never) { 366 | Ok(diff) => { 367 | let mut file = File::create(patch_path).unwrap(); 368 | file.write_all(&diff).unwrap(); 369 | } 370 | Err(err) => { 371 | let msg = format!("Unable to diff VBS: {err}"); 372 | prompt(&msg.truecolor(255, 125, 0).to_string()); 373 | } 374 | } 375 | } 376 | Some(TableOption::InfoShow) => match info_gather(selected_path) { 377 | Ok(info) => { 378 | prompt(&info); 379 | } 380 | Err(err) => { 381 | let msg = format!("Unable to gather table info: {err}"); 382 | prompt(&msg.truecolor(255, 125, 0).to_string()); 383 | } 384 | }, 385 | Some(TableOption::InfoEdit) => match info_edit(selected_path, Some(config)) { 386 | Ok(path) => { 387 | println!("Launched editor for {}", path.display()); 388 | } 389 | Err(err) => { 390 | let msg = format!("Unable to edit table info: {err}"); 391 | prompt_error(&msg); 392 | } 393 | }, 394 | Some(TableOption::InfoDiff) => match info_diff(selected_path) { 395 | Ok(diff) => { 396 | prompt(&diff); 397 | } 398 | Err(err) => { 399 | let msg = format!("Unable to diff info: {err}"); 400 | prompt_error(&msg); 401 | } 402 | }, 403 | Some(TableOption::NVRAMDipSwitches) => { 404 | nvram_dip_switches(info); 405 | } 406 | Some(TableOption::NVRAMShow) => { 407 | nvram_show(info); 408 | } 409 | Some(TableOption::NVRAMClear) => { 410 | nvram_clear(info); 411 | } 412 | Some(TableOption::B2SAutoPositionDMD) => match auto_position_dmd(config, &info) { 413 | Ok(msg) => { 414 | prompt(&msg); 415 | } 416 | Err(err) => { 417 | let msg = format!("Unable to auto-position DMD: {err}"); 418 | prompt_error(&msg); 419 | } 420 | }, 421 | Some(TableOption::EditTableINI) => { 422 | let path = ini_path_for(selected_path); 423 | if path.exists() { 424 | let result = open_editor(&path, Some(config)); 425 | report_launch_result(&path, result); 426 | } else if confirm( 427 | format!("Table ini {} does not exist", path.display()), 428 | "Do you want to create it?".to_string(), 429 | ) 430 | .unwrap_or(false) 431 | { 432 | let mut file = File::create(&path).unwrap(); 433 | file.write_all(b"").unwrap(); 434 | let result = open_editor(&path, Some(config)); 435 | report_launch_result(&path, result); 436 | } 437 | } 438 | Some(TableOption::EditMainIni) => { 439 | let path = &config.vpx_config; 440 | let result = if path.exists() { 441 | open_editor(path, Some(config)) 442 | } else { 443 | Err(io::Error::new( 444 | io::ErrorKind::NotFound, 445 | format!("Virtual Pinball ini {} does not exist.", path.display()), 446 | )) 447 | }; 448 | report_launch_result(path, result); 449 | } 450 | None => exit = true, 451 | } 452 | } 453 | } 454 | 455 | fn nvram_dip_switches(info: &IndexedTable) { 456 | if info.requires_pinmame { 457 | let nvram = nvram_for_rom(info); 458 | if let Some(nvram) = nvram { 459 | // open file in read/write mode 460 | match edit_dip_switches(nvram) { 461 | Ok(_) => { 462 | // ok 463 | } 464 | Err(err) => { 465 | let msg = format!("Unable to edit DIP switches: {err}"); 466 | prompt_error(&msg); 467 | } 468 | } 469 | } else { 470 | prompt("This table does not have an NVRAM file, try launching it once."); 471 | } 472 | } else { 473 | prompt("This table is not using PinMAME"); 474 | } 475 | } 476 | 477 | fn nvram_show(info: &IndexedTable) { 478 | if info.requires_pinmame { 479 | if let Some(nvram_path) = nvram_for_rom(info) { 480 | match pinmame_nvram::resolve::resolve(&nvram_path) { 481 | Ok(Some(resolved)) => { 482 | print!("{} NVRAM file: ", nvram_path.display()); 483 | // print as json 484 | let json = serde_json::to_string_pretty(&resolved).unwrap(); 485 | prompt(&json); 486 | } 487 | Ok(None) => { 488 | prompt(&format!("{} currently not supported", nvram_path.display())); 489 | } 490 | Err(err) => { 491 | let msg = format!("Unable to resolve NVRAM file: {err}"); 492 | prompt_error(&msg); 493 | } 494 | } 495 | } else { 496 | prompt("This table does not have an NVRAM file, try launching it once."); 497 | } 498 | } else { 499 | prompt("This table is not using PinMAME"); 500 | } 501 | } 502 | 503 | fn report_launch_result(path: &Path, result: io::Result<()>) { 504 | match result { 505 | Ok(_) => { 506 | println!("Launched editor for {}", path.display()); 507 | } 508 | Err(err) => { 509 | let msg = format!("Unable to launch editor for {err}"); 510 | prompt(&msg.truecolor(255, 125, 0).to_string()); 511 | } 512 | } 513 | } 514 | 515 | fn auto_position_dmd(config: &ResolvedConfig, info: &&IndexedTable) -> Result { 516 | match &info.b2s_path { 517 | Some(b2s_path) => { 518 | // TODO move image reading parsing code to vpin 519 | let reader = 520 | BufReader::new(File::open(b2s_path).map_err(|e| { 521 | format!("Unable to open B2S file {}: {}", b2s_path.display(), e) 522 | })?); 523 | let b2s = vpin::directb2s::read(reader) 524 | .map_err(|e| format!("Unable to read B2S file: {e}"))?; 525 | 526 | if let Some(dmd_image) = b2s.images.dmd_image { 527 | // load vpinball config 528 | 529 | let ini_file = &config.vpx_config; 530 | if ini_file.exists() { 531 | let base64data_with_cr_lf = dmd_image.value; 532 | let base64data = strip_cr_lf(&base64data_with_cr_lf); 533 | let decoded_data = base64::engine::general_purpose::STANDARD 534 | .decode(base64data) 535 | .map_err(|e| format!("Unable to decode base64 data: {e}"))?; 536 | // read the image with image crate 537 | let image = image::load_from_memory(&decoded_data) 538 | .map_err(|e| format!("Unable to read DMD image: {e}"))?; 539 | let hole_opt = find_hole(&image, 6, &image.width() / 2, 5) 540 | .map_err(|e| format!("Unable to find hole in DMD image: {e}"))?; 541 | if let Some(hole) = hole_opt { 542 | let table_ini_path = info.path.with_extension("ini"); 543 | let vpinball_config = VPinballConfig::read(ini_file) 544 | .map_err(|e| format!("Unable to read vpinball ini file: {e}"))?; 545 | let mut table_config = if table_ini_path.exists() { 546 | VPinballConfig::read(&table_ini_path) 547 | .map_err(|e| format!("Unable to read table ini file: {e}")) 548 | } else { 549 | Ok(VPinballConfig::default()) 550 | }?; 551 | 552 | let window_info = table_config 553 | .get_window_info(WindowType::B2SDMD) 554 | .or(vpinball_config.get_window_info(WindowType::B2SDMD)); 555 | 556 | if let Some(WindowInfo { 557 | x: Some(x), 558 | y: Some(y), 559 | width: Some(width), 560 | height: Some(height), 561 | .. 562 | }) = window_info 563 | { 564 | // Scale and position the hole to the vpinball FullDMD size. 565 | // We might want to preserve the aspect ratio. 566 | let hole = hole.scale_to_parent(width, height); 567 | 568 | let dmd_x = x + hole.x(); 569 | let dmd_y = y + hole.y(); 570 | if hole.width() < 10 || hole.height() < 10 { 571 | return Err( 572 | "Detected hole is too small, unable to update".to_string() 573 | ); 574 | } 575 | table_config.set_window_position(WindowType::PinMAME, dmd_x, dmd_y); 576 | table_config.set_window_size( 577 | WindowType::PinMAME, 578 | hole.width(), 579 | hole.height(), 580 | ); 581 | table_config.set_window_position(WindowType::FlexDMD, dmd_x, dmd_y); 582 | table_config.set_window_size( 583 | WindowType::FlexDMD, 584 | hole.width(), 585 | hole.height(), 586 | ); 587 | 588 | table_config.set_window_position(WindowType::DMD, dmd_x, dmd_y); 589 | table_config.set_window_size( 590 | WindowType::DMD, 591 | hole.width(), 592 | hole.height(), 593 | ); 594 | table_config.write(&table_ini_path).unwrap(); 595 | Ok(format!( 596 | "DMD window dimensions an position in {} updated to {}x{} at {},{}", 597 | table_ini_path.file_name().unwrap().to_string_lossy(), 598 | hole.width(), 599 | hole.height(), 600 | dmd_x, 601 | dmd_y 602 | )) 603 | } else { 604 | Err("Unable to find B2SDMD window or dimensions not specified in vpinball ini file".to_string()) 605 | } 606 | } else { 607 | Err("Unable to find hole in DMD image".to_string()) 608 | } 609 | } else { 610 | Err("Unable to read vpinball ini file".to_string()) 611 | } 612 | } else { 613 | Err("This table does not have a DMD image".to_string()) 614 | } 615 | } 616 | None => Err("This table does not have a B2S file".to_string()), 617 | } 618 | } 619 | 620 | fn edit_dip_switches(nvram: PathBuf) -> io::Result<()> { 621 | let nvram_map = Nvram::open(Path::new(&nvram))?.unwrap(); 622 | let disp_info = nvram_map.dip_switches_info()?; 623 | let mut nvram_file = OpenOptions::new().read(true).write(true).open(nvram)?; 624 | let mut switches = get_all_dip_switches(&mut nvram_file)?; 625 | 626 | let items = describe_switches(&disp_info, &switches); 627 | 628 | let defaults = switches.iter().map(|s| s.on).collect::>(); 629 | 630 | let help = "(<␣> selects, <⏎> saves, exits)" 631 | .dimmed() 632 | .to_string(); 633 | let prompt_string = format!("Toggle switches {help}"); 634 | let selection = MultiSelect::with_theme(&ColorfulTheme::default()) 635 | .with_prompt(prompt_string) 636 | .items(&items) 637 | .defaults(&defaults) 638 | .interact_opt()?; 639 | 640 | if let Some(selection) = selection { 641 | // update the switches 642 | switches.iter_mut().enumerate().for_each(|(i, s)| { 643 | s.on = selection.contains(&i); 644 | }); 645 | 646 | set_dip_switches(&mut nvram_file, &switches)?; 647 | prompt("DIP switches updated"); 648 | } 649 | Ok(()) 650 | } 651 | 652 | fn describe_switches(disp_info: &[DipSwitchInfo], switches: &[DipSwitchState]) -> Vec { 653 | switches 654 | .iter() 655 | .map(|s| { 656 | let info = disp_info.iter().find(|i| i.nr == s.nr); 657 | if let Some(info) = info 658 | && let Some(name) = &info.name 659 | { 660 | format!("DIP #{} - {}", s.nr, name) 661 | } else { 662 | format!("DIP #{}", s.nr) 663 | } 664 | }) 665 | .collect::>() 666 | } 667 | 668 | fn nvram_clear(info: &IndexedTable) { 669 | if info.requires_pinmame { 670 | let nvram_file = nvram_for_rom(info); 671 | if let Some(nvram_file) = nvram_file { 672 | if nvram_file.exists() { 673 | match confirm( 674 | "This will remove the table NVRAM file and you will lose all settings / high scores!".to_string(), 675 | "Are you sure?".to_string(), 676 | ) { 677 | Ok(true) => { 678 | match std::fs::remove_file(&nvram_file) { 679 | Ok(_) => { 680 | prompt(&format!("NVRAM file {} removed", nvram_file.display())); 681 | } 682 | Err(err) => { 683 | let msg = format!("Unable to remove NVRAM file: {err}"); 684 | prompt(&msg.truecolor(255, 125, 0).to_string()); 685 | } 686 | } 687 | } 688 | Ok(false) => { 689 | prompt("NVRAM file removal canceled."); 690 | } 691 | Err(err) => { 692 | let msg = format!("Error during confirmation: {err}"); 693 | prompt(&msg.truecolor(255, 125, 0).to_string()); 694 | } 695 | } 696 | } else { 697 | prompt(&format!( 698 | "NVRAM file {} does not exist", 699 | nvram_file.display() 700 | )); 701 | } 702 | } else { 703 | prompt("This table does not have an NVRAM file"); 704 | } 705 | } else { 706 | prompt("This table is not using used PinMAME"); 707 | } 708 | } 709 | 710 | /// Find the NVRAM file for a ROM, not checking if it exists 711 | fn nvram_for_rom(info: &IndexedTable) -> Option { 712 | info.rom_path().as_ref().and_then(|rom_path| { 713 | // ../nvram/[romname].nv 714 | rom_path.parent().and_then(|p| p.parent()).and_then(|p| { 715 | rom_path 716 | .file_name() 717 | .map(|file_name| p.join("nvram").join(file_name).with_extension("nv")) 718 | }) 719 | }) 720 | } 721 | 722 | fn prompt(msg: &str) { 723 | Input::::new() 724 | .with_prompt(format!("{msg} - Press enter to continue.")) 725 | .default("".to_string()) 726 | .show_default(false) 727 | .interact() 728 | .unwrap(); 729 | } 730 | 731 | fn prompt_error(msg: &str) { 732 | prompt(&msg.truecolor(255, 125, 0).to_string()); 733 | } 734 | 735 | fn choose_table_option( 736 | config: &ResolvedConfig, 737 | table_name: &str, 738 | selected: Option, 739 | ) -> Option { 740 | let mut default = 0; 741 | // iterate over table options 742 | let all_options = TableOption::all(config); 743 | let selections = all_options 744 | .iter() 745 | .enumerate() 746 | .map(|(index, option)| { 747 | if Some(option) == selected.as_ref() { 748 | default = index; 749 | } 750 | option.display() 751 | }) 752 | .collect::>(); 753 | if let Err(e) = Term::stderr().clear_screen() { 754 | eprintln!("Failed to clear screen: {e:?}"); 755 | } 756 | let selection_opt = Select::with_theme(&ColorfulTheme::default()) 757 | .with_prompt(table_name) 758 | .default(default) 759 | .items(&selections[..]) 760 | .interact_opt() 761 | .unwrap(); 762 | 763 | selection_opt.and_then(|index| all_options.get(index).cloned()) 764 | } 765 | 766 | fn launch(selected_path: &PathBuf, launch_template: &LaunchTemplate) { 767 | println!("{} {}", LAUNCH, selected_path.display()); 768 | 769 | let vpinball_executable = &launch_template.executable; 770 | 771 | if !vpinball_executable.is_executable() { 772 | report_and_exit(format!( 773 | "Unable to launch table, {} is not executable", 774 | vpinball_executable.display() 775 | )); 776 | } 777 | 778 | match launch_table(selected_path, launch_template) { 779 | Ok(status) => match status.code() { 780 | Some(0) => { 781 | //println!("Table exited normally"); 782 | } 783 | Some(11) => { 784 | prompt(&format!( 785 | "{CRASH} Visual Pinball exited with segfault, you might want to report this to the vpinball team." 786 | )); 787 | } 788 | Some(139) => { 789 | prompt(&format!( 790 | "{CRASH} Visual Pinball exited with segfault, you might want to report this to the vpinball team." 791 | )); 792 | } 793 | Some(code) => { 794 | prompt(&format!("{CRASH} Visual Pinball exited with code {code}")); 795 | } 796 | None => { 797 | prompt("Visual Pinball exited with unknown code"); 798 | } 799 | }, 800 | Err(e) => { 801 | if e.kind() == io::ErrorKind::NotFound { 802 | report_and_exit(format!( 803 | "Unable to launch table, vpinball executable not found at {}", 804 | vpinball_executable.display() 805 | )); 806 | } else { 807 | report_and_exit(format!("Unable to launch table: {e:?}")); 808 | } 809 | } 810 | } 811 | } 812 | 813 | fn report_and_exit(msg: String) -> ! { 814 | eprintln!("{CRASH} {msg}"); 815 | exit(1); 816 | } 817 | 818 | fn launch_table( 819 | selected_path: &PathBuf, 820 | launch_template: &LaunchTemplate, 821 | ) -> io::Result { 822 | let mut cmd = std::process::Command::new(&launch_template.executable); 823 | if let Some(env) = &launch_template.env { 824 | for (key, value) in env.iter() { 825 | cmd.env(key, value); 826 | } 827 | } 828 | if let Some(args) = &launch_template.arguments { 829 | cmd.args(args); 830 | } 831 | cmd.arg("-play"); 832 | cmd.arg(selected_path); 833 | 834 | println!("Spawning command: {cmd:?}"); 835 | 836 | let mut child = cmd.spawn()?; 837 | let result = child.wait()?; 838 | Ok(result) 839 | } 840 | 841 | fn display_table_line(table: &IndexedTable) -> String { 842 | let file_name = table 843 | .path 844 | .file_stem() 845 | .unwrap() 846 | .to_str() 847 | .unwrap() 848 | .to_string(); 849 | Some(table.table_info.table_name.to_owned()) 850 | .filter(|s| !s.clone().unwrap_or_default().is_empty()) 851 | .map(|s| { 852 | format!( 853 | "{} {}", 854 | capitalize_first_letter(s.unwrap_or_default().as_str()), 855 | format!("({file_name})").dimmed() 856 | ) 857 | }) 858 | .unwrap_or(file_name) 859 | } 860 | 861 | fn display_table_line_full(table: &IndexedTable) -> String { 862 | let base = display_table_line(table); 863 | let gamename_suffix = match &table.game_name { 864 | Some(name) => { 865 | let rom_found = table.rom_path().is_some(); 866 | if rom_found { 867 | format!(" - [{}]", name.dimmed()) 868 | } else if table.requires_pinmame { 869 | format!(" - {} [{}]", Emoji("⚠️", "!"), &name) 870 | .yellow() 871 | .to_string() 872 | } else { 873 | format!(" - [{}]", name.dimmed()) 874 | } 875 | } 876 | None => "".to_string(), 877 | }; 878 | let b2s_suffix = match &table.b2s_path { 879 | Some(_) => " ▀".dimmed(), 880 | None => "".into(), 881 | }; 882 | format!("{base}{gamename_suffix}{b2s_suffix}") 883 | } 884 | 885 | fn capitalize_first_letter(s: &str) -> String { 886 | s[0..1].to_uppercase() + &s[1..] 887 | } 888 | -------------------------------------------------------------------------------- /src/indexer.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use log::info; 3 | use rayon::prelude::*; 4 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 5 | use std::collections::{HashMap, HashSet}; 6 | use std::fmt::Debug; 7 | use std::fs::Metadata; 8 | use std::io::Read; 9 | use std::time::SystemTime; 10 | use std::{ 11 | ffi::OsStr, 12 | fs::{self, File}, 13 | io, 14 | path::{Path, PathBuf}, 15 | }; 16 | use vpin::vpx; 17 | use vpin::vpx::jsonmodel::json_to_info; 18 | use vpin::vpx::tableinfo::TableInfo; 19 | use walkdir::{DirEntry, FilterEntry, IntoIter, WalkDir}; 20 | 21 | use vpx::gamedata::GameData; 22 | 23 | pub const DEFAULT_INDEX_FILE_NAME: &str = "vpxtool_index.json"; 24 | 25 | /// Introduced because we want full control over serialization 26 | #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] 27 | pub struct IndexedTableInfo { 28 | pub table_name: Option, 29 | pub author_name: Option, 30 | //pub screenshot: Option>, 31 | pub table_blurb: Option, 32 | pub table_rules: Option, 33 | pub author_email: Option, 34 | pub release_date: Option, 35 | pub table_save_rev: Option, 36 | pub table_version: Option, 37 | pub author_website: Option, 38 | pub table_save_date: Option, 39 | pub table_description: Option, 40 | // the keys (and ordering) for these are defined in "GameStg/CustomInfoTags" 41 | pub properties: HashMap, 42 | } 43 | impl From for IndexedTableInfo { 44 | fn from(table_info: TableInfo) -> Self { 45 | IndexedTableInfo { 46 | table_name: table_info.table_name, 47 | author_name: table_info.author_name, 48 | //screenshot: table_info.screenshot, // TODO we might want to write this to a file next to the table? 49 | table_blurb: table_info.table_blurb, 50 | table_rules: table_info.table_rules, 51 | author_email: table_info.author_email, 52 | release_date: table_info.release_date, 53 | table_save_rev: table_info.table_save_rev, 54 | table_version: table_info.table_version, 55 | author_website: table_info.author_website, 56 | table_save_date: table_info.table_save_date, 57 | table_description: table_info.table_description, 58 | properties: table_info.properties, 59 | } 60 | } 61 | } 62 | 63 | pub struct PathWithMetadata { 64 | pub path: PathBuf, 65 | pub last_modified: SystemTime, 66 | } 67 | 68 | #[derive(Clone, Copy, PartialEq, Debug, Eq, Ord, PartialOrd)] 69 | pub struct IsoSystemTime(SystemTime); 70 | impl From for IsoSystemTime { 71 | fn from(system_time: SystemTime) -> Self { 72 | IsoSystemTime(system_time) 73 | } 74 | } 75 | impl From for SystemTime { 76 | fn from(iso_system_time: IsoSystemTime) -> Self { 77 | iso_system_time.0 78 | } 79 | } 80 | impl Serialize for IsoSystemTime { 81 | fn serialize(&self, serializer: S) -> Result 82 | where 83 | S: Serializer, 84 | { 85 | let now: DateTime = self.0.into(); 86 | now.to_rfc3339().serialize(serializer) 87 | } 88 | } 89 | impl<'de> Deserialize<'de> for IsoSystemTime { 90 | fn deserialize(deserializer: D) -> Result 91 | where 92 | D: Deserializer<'de>, 93 | { 94 | let s = String::deserialize(deserializer)?; 95 | let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?; 96 | Ok(IsoSystemTime(dt.into())) 97 | } 98 | } 99 | 100 | #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] 101 | pub struct IndexedTable { 102 | pub path: PathBuf, 103 | pub table_info: IndexedTableInfo, 104 | pub game_name: Option, 105 | pub b2s_path: Option, 106 | /// The rom path, in the table folder or in the global pinmame roms folder 107 | rom_path: Option, 108 | /// deprecated: only used for reading the old index format 109 | #[serde(skip_serializing_if = "Option::is_none")] 110 | local_rom_path: Option, 111 | pub wheel_path: Option, 112 | pub requires_pinmame: bool, 113 | pub last_modified: IsoSystemTime, 114 | } 115 | 116 | impl IndexedTable { 117 | pub fn rom_path(&self) -> Option<&PathBuf> { 118 | self.rom_path.as_ref().or(self.local_rom_path.as_ref()) 119 | } 120 | } 121 | 122 | #[derive(Serialize, Deserialize, PartialEq, Debug)] 123 | pub struct TablesIndex { 124 | tables: HashMap, 125 | } 126 | 127 | impl TablesIndex { 128 | pub(crate) fn empty() -> TablesIndex { 129 | TablesIndex { 130 | tables: HashMap::new(), 131 | } 132 | } 133 | 134 | pub fn len(&self) -> usize { 135 | self.tables.len() 136 | } 137 | 138 | pub fn is_empty(&self) -> bool { 139 | self.tables.is_empty() 140 | } 141 | 142 | pub(crate) fn insert(&mut self, table: IndexedTable) { 143 | self.tables.insert(table.path.clone(), table); 144 | } 145 | 146 | pub fn insert_all(&mut self, new_tables: Vec) { 147 | for table in new_tables { 148 | self.insert(table); 149 | } 150 | } 151 | 152 | pub fn merge(&mut self, other: TablesIndex) { 153 | self.tables.extend(other.tables); 154 | } 155 | 156 | pub fn tables(&self) -> Vec { 157 | self.tables.values().cloned().collect() 158 | } 159 | 160 | pub(crate) fn should_index(&self, path_with_metadata: &PathWithMetadata) -> bool { 161 | // if exists with different last modified or missing 162 | match self.tables.get(&path_with_metadata.path) { 163 | Some(existing) => { 164 | let existing_last_modified: SystemTime = existing.last_modified.into(); 165 | existing_last_modified != path_with_metadata.last_modified 166 | } 167 | None => true, 168 | } 169 | } 170 | 171 | pub(crate) fn remove_missing(&mut self, paths: &[PathWithMetadata]) -> usize { 172 | // create a hashset with the paths 173 | let len = self.tables.len(); 174 | let paths_set: HashSet = paths.iter().map(|p| p.path.clone()).collect(); 175 | self.tables.retain(|path, _| paths_set.contains(path)); 176 | len - self.tables.len() 177 | } 178 | } 179 | 180 | /// We prefer keeping a flat index instead of an object 181 | #[derive(Serialize, Deserialize, PartialEq, Debug)] 182 | pub struct TablesIndexJson { 183 | tables: Vec, 184 | } 185 | 186 | impl From for TablesIndexJson { 187 | fn from(index: TablesIndex) -> Self { 188 | TablesIndexJson { 189 | tables: index.tables(), 190 | } 191 | } 192 | } 193 | 194 | impl From<&TablesIndex> for TablesIndexJson { 195 | fn from(table: &TablesIndex) -> Self { 196 | TablesIndexJson { 197 | tables: table.tables(), 198 | } 199 | } 200 | } 201 | 202 | impl From for TablesIndex { 203 | fn from(index: TablesIndexJson) -> Self { 204 | let mut tables = HashMap::new(); 205 | for table in index.tables { 206 | tables.insert(table.path.clone(), table); 207 | } 208 | TablesIndex { tables } 209 | } 210 | } 211 | 212 | /// Returns all roms names lower case for the roms in the given folder 213 | pub fn find_roms(rom_path: &Path) -> io::Result> { 214 | if !rom_path.exists() { 215 | return Ok(HashMap::new()); 216 | } 217 | // TODO 218 | // TODO if there is an ini file for the table we might have to check locally for the rom 219 | // currently only a standalone feature 220 | let mut roms = HashMap::new(); 221 | // TODO is there a cleaner version like try_filter_map? 222 | let mut entries = fs::read_dir(rom_path)?; 223 | entries.try_for_each(|entry| { 224 | let dir_entry = entry?; 225 | let path = dir_entry.path(); 226 | if path.is_file() 227 | && let Some("zip") = path.extension().and_then(OsStr::to_str) 228 | { 229 | let rom_name = path 230 | .file_stem() 231 | .unwrap() 232 | .to_str() 233 | .unwrap() 234 | .to_string() 235 | .to_lowercase(); 236 | roms.insert(rom_name, path); 237 | } 238 | Ok::<(), io::Error>(()) 239 | })?; 240 | Ok(roms) 241 | } 242 | 243 | pub fn find_vpx_files(recursive: bool, tables_path: &Path) -> io::Result> { 244 | if recursive { 245 | let mut vpx_files = Vec::new(); 246 | let mut entries = walk_dir_filtered(tables_path); 247 | entries.try_for_each(|entry| { 248 | let dir_entry = entry?; 249 | let path = dir_entry.path(); 250 | if path.is_file() 251 | && let Some("vpx") = path.extension().and_then(OsStr::to_str) 252 | { 253 | let last_modified = last_modified(path)?; 254 | vpx_files.push(PathWithMetadata { 255 | path: path.to_path_buf(), 256 | last_modified, 257 | }); 258 | } 259 | Ok::<(), io::Error>(()) 260 | })?; 261 | Ok(vpx_files) 262 | } else { 263 | let mut vpx_files = Vec::new(); 264 | // TODO is there a cleaner version like try_filter_map? 265 | let mut dirs = fs::read_dir(tables_path)?; 266 | dirs.try_for_each(|entry| { 267 | let dir_entry = entry?; 268 | let path = dir_entry.path(); 269 | if path.is_file() 270 | && let Some("vpx") = path.extension().and_then(OsStr::to_str) 271 | { 272 | let last_modified = last_modified(&path)?; 273 | vpx_files.push(PathWithMetadata { 274 | path: path.to_path_buf(), 275 | last_modified, 276 | }); 277 | } 278 | Ok::<(), io::Error>(()) 279 | })?; 280 | Ok(vpx_files) 281 | } 282 | } 283 | 284 | /// Walks the directory and filters out .git and __MACOSX folders 285 | fn walk_dir_filtered(tables_path: &Path) -> FilterEntry bool> { 286 | WalkDir::new(tables_path).into_iter().filter_entry(|entry| { 287 | let path = entry.path(); 288 | let git = std::path::Component::Normal(".git".as_ref()); 289 | let macosx = std::path::Component::Normal("__MACOSX".as_ref()); 290 | !path.components().any(|c| c == git) && !path.components().any(|c| c == macosx) 291 | }) 292 | } 293 | 294 | pub trait Progress { 295 | fn set_length(&self, len: u64); 296 | fn set_position(&self, i: u64); 297 | fn finish_and_clear(&self); 298 | } 299 | 300 | pub struct VoidProgress; 301 | impl Progress for VoidProgress { 302 | fn set_length(&self, _len: u64) {} 303 | fn set_position(&self, _i: u64) {} 304 | fn finish_and_clear(&self) {} 305 | } 306 | 307 | pub enum IndexError { 308 | FolderDoesNotExist(PathBuf), 309 | IoError(io::Error), 310 | } 311 | impl Debug for IndexError { 312 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 313 | match self { 314 | IndexError::FolderDoesNotExist(path) => { 315 | write!(f, "Folder does not exist: {}", path.display()) 316 | } 317 | IndexError::IoError(e) => write!(f, "IO error: {e}"), 318 | } 319 | } 320 | } 321 | impl From for io::Error { 322 | fn from(e: IndexError) -> io::Error { 323 | io::Error::other(format!("{e:?}")) 324 | } 325 | } 326 | 327 | impl From for IndexError { 328 | fn from(e: io::Error) -> Self { 329 | IndexError::IoError(e) 330 | } 331 | } 332 | 333 | /// Indexes all vpx files in the given folder and writes the index to a file. 334 | /// Returns the index. 335 | /// If the index file already exists, it will be read and updated. 336 | /// If the index file does not exist, it will be created. 337 | /// 338 | /// Arguments: 339 | /// * `recursive`: if true, all subdirectories will be searched for vpx files. 340 | /// * `tables_folder`: the folder to search for vpx files. 341 | /// * `tables_index_path`: the path to the index file. 342 | /// * `global_pinmame_path`: the path to the global pinmame folder. Eg ~/.pinmame/roms on *nix systems. 343 | /// * `configured_pinmame_path`: the path to the local pinmame folder configured in the vpinball config. 344 | /// * `progress`: lister for progress updates. 345 | /// * `force_reindex`: a list of vpx files to reindex, even if they are not modified. 346 | pub fn index_folder( 347 | recursive: bool, 348 | tables_folder: &Path, 349 | tables_index_path: &Path, 350 | global_pinmame_path: Option<&Path>, 351 | configured_pinmame_path: Option<&Path>, 352 | progress: &impl Progress, 353 | force_reindex: Vec, 354 | ) -> Result { 355 | info!("Indexing {}", tables_folder.display()); 356 | 357 | if !tables_folder.exists() { 358 | return Err(IndexError::FolderDoesNotExist(tables_folder.to_path_buf())); 359 | } 360 | 361 | let existing_index = read_index_json(tables_index_path)?; 362 | if let Some(index) = &existing_index { 363 | info!( 364 | " Found existing index with {} tables at {}", 365 | index.tables.len(), 366 | tables_index_path.display() 367 | ); 368 | } 369 | let mut index = existing_index.unwrap_or(TablesIndex::empty()); 370 | 371 | let vpx_files = find_vpx_files(recursive, tables_folder)?; 372 | info!(" Found {} tables", vpx_files.len()); 373 | // remove files that are missing 374 | let removed_len = index.remove_missing(&vpx_files); 375 | info!(" {removed_len} missing tables have been removed"); 376 | 377 | let tables_with_missing_rom = index 378 | .tables() 379 | .iter() 380 | .filter_map(|table| { 381 | table 382 | .rom_path() 383 | .filter(|rom_path| !rom_path.exists()) 384 | .map(|_| table.path.clone()) 385 | }) 386 | .collect::>(); 387 | info!( 388 | " {} tables will be re-indexed because their rom is missing", 389 | tables_with_missing_rom.len() 390 | ); 391 | 392 | // find files that are missing or have been modified 393 | let mut vpx_files_to_index = Vec::new(); 394 | for vpx_file in vpx_files { 395 | if tables_with_missing_rom.contains(&vpx_file.path) 396 | || force_reindex.contains(&vpx_file.path) 397 | || index.should_index(&vpx_file) 398 | { 399 | vpx_files_to_index.push(vpx_file); 400 | } 401 | } 402 | 403 | info!(" {} tables need (re)indexing.", vpx_files_to_index.len()); 404 | let vpx_files_with_table_info = index_vpx_files( 405 | vpx_files_to_index, 406 | global_pinmame_path, 407 | configured_pinmame_path, 408 | progress, 409 | )?; 410 | 411 | // add new files to index 412 | index.merge(vpx_files_with_table_info); 413 | 414 | // write the index to a file 415 | write_index_json(&index, tables_index_path)?; 416 | 417 | Ok(index) 418 | } 419 | 420 | /// Indexes all vpx files in the given folder and returns the index. 421 | /// note: The index is unordered, so the order of the tables is not guaranteed. 422 | /// 423 | /// Arguments: 424 | /// * `vpx_files`: the vpx files to index. 425 | /// * `global_roms_path`: the path to the global roms folder. Eg ~/.pinmame/roms on *nix systems. 426 | /// * `pinmame_roms_path`: the path to the local pinmame roms folder configured in the vpinball config 427 | /// * `progress`: lister for progress updates. 428 | /// 429 | /// see https://github.com/francisdb/vpxtool/issues/526 430 | pub fn index_vpx_files( 431 | vpx_files: Vec, 432 | global_pinmame_path: Option<&Path>, 433 | configured_pinmame_path: Option<&Path>, 434 | progress: &impl Progress, 435 | ) -> io::Result { 436 | let global_roms = global_pinmame_path 437 | .map(|pinmame_path| { 438 | let roms_path = pinmame_path.join("roms"); 439 | find_roms(&roms_path) 440 | }) 441 | .unwrap_or_else(|| Ok(HashMap::new()))?; 442 | 443 | let pinmame_roms_path = configured_pinmame_path.map(|p| p.join("roms").to_path_buf()); 444 | 445 | let (progress_tx, progress_rx) = std::sync::mpsc::channel(); 446 | 447 | progress.set_length(vpx_files.len() as u64); 448 | let index_thread = std::thread::spawn(move || { 449 | vpx_files 450 | .par_iter() 451 | .flat_map(|vpx_file| { 452 | let res = match index_vpx_file(vpx_file, pinmame_roms_path.as_deref(), &global_roms) 453 | { 454 | Ok(indexed_table) => Some(indexed_table), 455 | Err(e) => { 456 | // TODO we want to return any failures instead of printing here 457 | let warning = 458 | format!("Not a valid vpx file {}: {}", vpx_file.path.display(), e); 459 | println!("{warning}"); 460 | None 461 | } 462 | }; 463 | // We don't care if something fails, it's just progress reporting. 464 | let _ = progress_tx.send(1); 465 | res 466 | }) 467 | .collect() 468 | }); 469 | 470 | let mut finished = 0; 471 | // The sender is automatically closed when it goes out of scope, we can be sure 472 | // that this does not block forever. 473 | for i in progress_rx { 474 | finished += i; 475 | progress.set_position(finished); 476 | } 477 | 478 | let vpx_files_with_table_info = index_thread 479 | .join() 480 | .map_err(|e| io::Error::other(format!("{e:?}")))?; 481 | 482 | Ok(TablesIndex { 483 | tables: vpx_files_with_table_info, 484 | }) 485 | } 486 | 487 | fn index_vpx_file( 488 | vpx_file_path: &PathWithMetadata, 489 | configured_roms_path: Option<&Path>, 490 | global_roms: &HashMap, 491 | ) -> io::Result<(PathBuf, IndexedTable)> { 492 | let path = &vpx_file_path.path; 493 | let mut vpx_file = vpx::open(path)?; 494 | // if there's an .info.json file, we should use that instead of the info in the vpx file 495 | let info_file_path = path.with_extension("info.json"); 496 | let table_info = if info_file_path.exists() { 497 | read_table_info_json(&info_file_path) 498 | } else { 499 | vpx_file.read_tableinfo() 500 | }?; 501 | let game_data = vpx_file.read_gamedata()?; 502 | let code = consider_sidecar_vbs(path, game_data)?; 503 | // also this sidecar should be part of the cache key 504 | let game_name = extract_game_name(&code); 505 | let requires_pinmame = requires_pinmame(&code); 506 | let rom_path = find_local_rom_path(path, &game_name, configured_roms_path)?.or_else(|| { 507 | game_name 508 | .as_ref() 509 | .and_then(|game_name| global_roms.get(&game_name.to_lowercase()).cloned()) 510 | }); 511 | let b2s_path = find_b2s_path(path); 512 | let wheel_path = find_wheel_path(path); 513 | let last_modified = last_modified(path)?; 514 | let indexed_table_info = IndexedTableInfo::from(table_info); 515 | 516 | let indexed = IndexedTable { 517 | path: path.clone(), 518 | table_info: indexed_table_info, 519 | game_name, 520 | b2s_path, 521 | rom_path, 522 | local_rom_path: None, 523 | wheel_path, 524 | requires_pinmame, 525 | last_modified: IsoSystemTime(last_modified), 526 | }; 527 | Ok((indexed.path.clone(), indexed)) 528 | } 529 | 530 | pub fn get_romname_from_vpx(vpx_path: &Path) -> io::Result> { 531 | let mut vpx_file = vpx::open(vpx_path)?; 532 | let game_data = vpx_file.read_gamedata()?; 533 | let code = consider_sidecar_vbs(vpx_path, game_data)?; 534 | let game_name = extract_game_name(&code); 535 | let requires_pinmame = requires_pinmame(&code); 536 | if requires_pinmame { 537 | Ok(game_name) 538 | } else { 539 | Ok(None) 540 | } 541 | } 542 | 543 | fn read_table_info_json(info_file_path: &Path) -> io::Result { 544 | let mut info_file = File::open(info_file_path)?; 545 | let json = serde_json::from_reader(&mut info_file).map_err(|e| { 546 | io::Error::other(format!( 547 | "Failed to parse/read json {}: {}", 548 | info_file_path.display(), 549 | e 550 | )) 551 | })?; 552 | let (table_info, _custom_info_tags) = json_to_info(json, None)?; 553 | Ok(table_info) 554 | } 555 | 556 | /// Visual pinball always falls back to the [vpx_folder]/pinmame/roms folder, 557 | /// even if the PinMAMEPath folder is configured in the vpinball config. 558 | fn find_local_rom_path( 559 | vpx_file_path: &Path, 560 | game_name: &Option, 561 | configured_roms_path: Option<&Path>, 562 | ) -> io::Result> { 563 | if let Some(game_name) = game_name { 564 | let rom_file_name = format!("{}.zip", game_name.to_lowercase()); 565 | 566 | let pinmame_roms_path = if let Some(configured_roms_path) = configured_roms_path { 567 | let configured_roms_path = if configured_roms_path.is_relative() { 568 | vpx_file_path.parent().unwrap().join(configured_roms_path) 569 | } else { 570 | configured_roms_path.to_owned() 571 | }; 572 | if configured_roms_path.exists() { 573 | configured_roms_path 574 | } else { 575 | vpx_file_path.parent().unwrap().join("pinmame").join("roms") 576 | } 577 | } else { 578 | vpx_file_path.parent().unwrap().join("pinmame").join("roms") 579 | }; 580 | 581 | let rom_path = pinmame_roms_path.join(rom_file_name); 582 | return if rom_path.exists() { 583 | Ok(Some(rom_path.canonicalize()?)) 584 | } else { 585 | Ok(None) 586 | }; 587 | }; 588 | Ok(None) 589 | } 590 | 591 | fn find_b2s_path(vpx_file_path: &Path) -> Option { 592 | let b2s_file_name = format!( 593 | "{}.directb2s", 594 | vpx_file_path.file_stem().unwrap().to_string_lossy() 595 | ); 596 | let b2s_path = vpx_file_path.parent().unwrap().join(b2s_file_name); 597 | if b2s_path.exists() { 598 | Some(b2s_path) 599 | } else { 600 | None 601 | } 602 | } 603 | 604 | /// Tries to find a wheel image for the given vpx file. 605 | /// 2 locations are tried: 606 | /// * ../wheels/.png 607 | /// * .wheel.png 608 | fn find_wheel_path(vpx_file_path: &Path) -> Option { 609 | let wheel_file_name = format!( 610 | "wheels/{}.png", 611 | vpx_file_path.file_stem().unwrap().to_string_lossy() 612 | ); 613 | let wheel_path = vpx_file_path.parent().unwrap().join(wheel_file_name); 614 | if wheel_path.exists() { 615 | return Some(wheel_path); 616 | } 617 | let wheel_path = vpx_file_path.with_extension("wheel.png"); 618 | if wheel_path.exists() { 619 | return Some(wheel_path); 620 | } 621 | None 622 | } 623 | 624 | /// If there is a file with the same name and extension .vbs we pick that code 625 | /// instead of the code in the vpx file. 626 | /// 627 | /// TODO if this file changes the index entry is currently not invalidated 628 | fn consider_sidecar_vbs(path: &Path, game_data: GameData) -> io::Result { 629 | let vbs_path = path.with_extension("vbs"); 630 | let code = if vbs_path.exists() { 631 | let mut vbs_file = File::open(vbs_path)?; 632 | let mut code = String::new(); 633 | vbs_file.read_to_string(&mut code)?; 634 | code 635 | } else { 636 | game_data.code.string 637 | }; 638 | Ok(code) 639 | } 640 | 641 | fn last_modified(path: &Path) -> io::Result { 642 | let metadata: Metadata = path.metadata()?; 643 | metadata.modified() 644 | } 645 | 646 | pub fn write_index_json(indexed_tables: &TablesIndex, json_path: &Path) -> io::Result<()> { 647 | let json_file = File::create(json_path)?; 648 | let indexed_tables_json: TablesIndexJson = indexed_tables.into(); 649 | serde_json::to_writer_pretty(json_file, &indexed_tables_json).map_err(io::Error::other) 650 | } 651 | 652 | pub fn read_index_json(json_path: &Path) -> io::Result> { 653 | if !json_path.exists() { 654 | return Ok(None); 655 | } 656 | let json_file = File::open(json_path)?; 657 | match serde_json::from_reader::<_, TablesIndexJson>(json_file) { 658 | Ok(indexed_tables_json) => { 659 | let indexed_tables: TablesIndex = indexed_tables_json.into(); 660 | Ok(Some(indexed_tables)) 661 | } 662 | Err(e) => { 663 | println!("Failed to parse index file, ignoring existing index. ({e})"); 664 | Ok(None) 665 | } 666 | } 667 | } 668 | 669 | fn extract_game_name>(code: S) -> Option { 670 | // TODO can we find a first match through an option? 671 | // needs to be all lowercase to match with (?i) case insensitive 672 | const LINE_WITH_CGAMENAME_RE: &str = 673 | r#"(?i)(?:.*?)*cgamename\s*=\s*\"([^"\\]*(?:\\.[^"\\]*)*)\""#; 674 | const LINE_WITH_DOT_GAMENAME_RE: &str = 675 | r#"(?i)(?:.*?)\.gamename\s*=\s*\"([^"\\]*(?:\\.[^"\\]*)*)\""#; 676 | let cgamename_re = regex::Regex::new(LINE_WITH_CGAMENAME_RE).unwrap(); 677 | let dot_gamename_re = regex::Regex::new(LINE_WITH_DOT_GAMENAME_RE).unwrap(); 678 | let unified = unify_line_endings(code.as_ref()); 679 | unified 680 | .lines() 681 | // skip rows that start with ' or whitespace followed by ' 682 | .filter(|line| !line.trim().starts_with('\'')) 683 | .filter(|line| { 684 | let lower: String = line.to_owned().to_lowercase().trim().to_string(); 685 | lower.contains("cgamename") || lower.contains(".gamename") 686 | }) 687 | .flat_map(|line| { 688 | let caps = cgamename_re 689 | .captures(line) 690 | .or(dot_gamename_re.captures(line))?; 691 | let first = caps.get(1)?; 692 | Some(first.as_str().to_string()) 693 | }) 694 | .next() 695 | } 696 | 697 | fn requires_pinmame>(code: S) -> bool { 698 | let unified = unify_line_endings(code.as_ref()); 699 | let lower = unified.to_lowercase(); 700 | const RE: &str = r#"sub\s*loadvpm"#; 701 | let re = regex::Regex::new(RE).unwrap(); 702 | lower 703 | .lines() 704 | .filter(|line| !line.trim().starts_with('\'')) 705 | .any(|line| line.contains("loadvpm") && !re.is_match(line)) 706 | } 707 | 708 | /// Some scripts contain only CR as line separator. Eg "Monte Carlo (Premier 1987) (10.7) 1.6.vpx" 709 | /// Therefore we replace first all CRLF and then all leftover CR with LF 710 | fn unify_line_endings(code: &str) -> String { 711 | code.replace("\r\n", "\n").replace('\r', "\n") 712 | } 713 | 714 | #[cfg(test)] 715 | mod tests { 716 | use super::*; 717 | use pretty_assertions::assert_eq; 718 | use serde_json::json; 719 | use std::io::Write; 720 | use testdir::testdir; 721 | use vpin::vpx; 722 | 723 | #[test] 724 | fn test_index_vpx_files() -> io::Result<()> { 725 | // Test setup looks like this: 726 | // test_dir/ 727 | // ├── test.vpx 728 | // ├── test2.vpx 729 | // ├── subdir 730 | // │ └── test3.vpx 731 | // ├── test3.vpx 732 | // ├── __MACOSX/ 733 | // │ └── ignored.vpx 734 | // ├── .git/ 735 | // │ └── ignored2.vpx 736 | // ├── pinmame/ 737 | // │ └── roms/ 738 | // │ └── testgamename.zip 739 | // global_pinmame/ 740 | // ├── roms/ 741 | // │ └── testgamename2.zip 742 | let global_pinmame_dir = testdir!().join("global_pinmame"); 743 | fs::create_dir(&global_pinmame_dir)?; 744 | let global_roms_dir = global_pinmame_dir.join("roms"); 745 | fs::create_dir(&global_roms_dir)?; 746 | let tables_dir = testdir!().join("tables"); 747 | fs::create_dir(&tables_dir)?; 748 | let temp_dir = testdir!().join("temp"); 749 | fs::create_dir(&temp_dir)?; 750 | // the next two folders should be ignored 751 | let macosx = tables_dir.join("__MACOSX"); 752 | fs::create_dir(&macosx)?; 753 | File::create(macosx.join("ignored.vpx"))?; 754 | let git = tables_dir.join(".git"); 755 | fs::create_dir(&git)?; 756 | File::create(git.join("ignored2.vpx"))?; 757 | fs::create_dir(tables_dir.join("subdir"))?; 758 | // actual vpx files to index 759 | let vpx_1_path = tables_dir.join("test.vpx"); 760 | let vpx_2_path = tables_dir.join("test2.vpx"); 761 | let vpx_3_path = tables_dir.join("subdir").join("test3.vpx"); 762 | 763 | vpx::new_minimal_vpx(&vpx_1_path)?; 764 | let script1 = test_script(&temp_dir, "testgamename")?; 765 | vpx::importvbs(&vpx_1_path, Some(script1))?; 766 | // local rom 767 | let mut rom1_path_local = tables_dir 768 | .join("pinmame") 769 | .join("roms") 770 | .join("testgamename.zip"); 771 | // recursively create dir 772 | fs::create_dir_all(rom1_path_local.parent().unwrap())?; 773 | File::create(&rom1_path_local)?; 774 | // this canonicalize makes a strange dir starting with //?/ 775 | rom1_path_local = rom1_path_local.canonicalize()?; 776 | 777 | vpx::new_minimal_vpx(&vpx_2_path)?; 778 | let script2 = test_script(&temp_dir, "testgamename2")?; 779 | vpx::importvbs(&vpx_2_path, Some(script2))?; 780 | // global rom 781 | let rom2_path_global = global_roms_dir.join("testgamename2.zip"); 782 | File::create(&rom2_path_global)?; 783 | 784 | vpx::new_minimal_vpx(&vpx_3_path)?; 785 | // no rom 786 | 787 | // let output = std::process::Command::new("tree") 788 | // .arg(&tables_dir) 789 | // .output() 790 | // .expect("failed to execute process"); 791 | // let output_str = String::from_utf8_lossy(&output.stdout); 792 | // println!("test_dir:\n{}", output_str); 793 | // 794 | // let output = std::process::Command::new("tree") 795 | // .arg(&global_pinmame_dir) 796 | // .output() 797 | // .expect("failed to execute process"); 798 | // let output_str = String::from_utf8_lossy(&output.stdout); 799 | // println!("global_pinmame_dir:\n{}", output_str); 800 | 801 | let vpx_files = find_vpx_files(true, &tables_dir)?; 802 | assert_eq!(vpx_files.len(), 3); 803 | let global_roms = find_roms(&global_roms_dir)?; 804 | assert_eq!(global_roms.len(), 1); 805 | let configured_roms_path = Some(PathBuf::from("./")); 806 | let indexed_tables = index_vpx_files( 807 | vpx_files, 808 | Some(&global_pinmame_dir), 809 | configured_roms_path.as_deref(), 810 | &VoidProgress, 811 | )?; 812 | assert_eq!(indexed_tables.tables.len(), 3); 813 | let table1 = indexed_tables 814 | .tables 815 | .get(&vpx_1_path) 816 | .expect("table1 not found"); 817 | let table2 = indexed_tables 818 | .tables 819 | .get(&vpx_2_path) 820 | .expect("table2 not found"); 821 | let table3 = indexed_tables 822 | .tables 823 | .get(&vpx_3_path) 824 | .expect("table3 not found"); 825 | assert_eq!(table1.path, vpx_1_path); 826 | assert_eq!(table2.path, vpx_2_path); 827 | assert_eq!(table3.path, vpx_3_path); 828 | assert_eq!(table1.rom_path, Some(rom1_path_local.clone())); 829 | assert_eq!(table2.rom_path, Some(rom2_path_global.clone())); 830 | assert_eq!(table3.rom_path, None); 831 | Ok(()) 832 | } 833 | 834 | fn test_script(temp_dir: &Path, game_name: &str) -> io::Result { 835 | // write simple script in tempdir 836 | let script = format!( 837 | r#" 838 | Const cGameName = "{game_name}" 839 | Sub LoadVPM 840 | "# 841 | ); 842 | let script_path = temp_dir.join(game_name).with_extension("vbs"); 843 | let mut script_file = File::create(&script_path)?; 844 | script_file.write_all(script.as_bytes())?; 845 | Ok(script_path) 846 | } 847 | 848 | #[test] 849 | fn test_write_read_empty_array() -> io::Result<()> { 850 | let index = TablesIndex::empty(); 851 | let test_dir = testdir!(); 852 | let index_path = test_dir.join("test.json"); 853 | // write empty json array using serde_json 854 | let json_file = File::create(&index_path)?; 855 | let json_object = json!({ 856 | "tables": [] 857 | }); 858 | serde_json::to_writer_pretty(json_file, &json_object)?; 859 | let read = read_index_json(&index_path)?; 860 | assert_eq!(read, Some(index)); 861 | Ok(()) 862 | } 863 | 864 | #[test] 865 | fn test_write_read_invalid_file() -> io::Result<()> { 866 | let test_dir = testdir!(); 867 | let index_path = test_dir.join("test.json"); 868 | // write empty json array using serde_json 869 | let json_file = File::create(&index_path)?; 870 | // write garbage to file 871 | serde_json::to_writer_pretty(json_file, &"garbage")?; 872 | let read = read_index_json(&index_path)?; 873 | assert_eq!(read, None); 874 | Ok(()) 875 | } 876 | 877 | #[test] 878 | fn test_write_read_empty_index() -> io::Result<()> { 879 | let index = TablesIndex::empty(); 880 | let test_dir = testdir!(); 881 | let index_path = test_dir.join("test.json"); 882 | write_index_json(&index, &index_path)?; 883 | let read = read_index_json(&index_path)?; 884 | assert_eq!(read, Some(index)); 885 | Ok(()) 886 | } 887 | 888 | #[test] 889 | fn test_write_read_single_item_index() -> io::Result<()> { 890 | let mut index = TablesIndex::empty(); 891 | index.insert(IndexedTable { 892 | path: PathBuf::from("test.vpx"), 893 | table_info: IndexedTableInfo { 894 | table_name: Some("test".to_string()), 895 | author_name: Some("test".to_string()), 896 | table_blurb: None, 897 | table_rules: None, 898 | author_email: None, 899 | release_date: None, 900 | table_save_rev: None, 901 | table_version: None, 902 | author_website: None, 903 | table_save_date: None, 904 | table_description: None, 905 | properties: HashMap::new(), 906 | }, 907 | game_name: Some("testrom".to_string()), 908 | b2s_path: Some(PathBuf::from("test.b2s")), 909 | rom_path: Some(PathBuf::from("testrom.zip")), 910 | local_rom_path: None, 911 | wheel_path: Some(PathBuf::from("test.png")), 912 | requires_pinmame: true, 913 | last_modified: IsoSystemTime(SystemTime::UNIX_EPOCH), 914 | }); 915 | let test_dir = testdir!(); 916 | let index_path = test_dir.join("test.json"); 917 | write_index_json(&index, &index_path)?; 918 | let read = read_index_json(&index_path)?; 919 | assert_eq!(read, Some(index)); 920 | Ok(()) 921 | } 922 | 923 | #[test] 924 | fn test_read_index_missing() -> io::Result<()> { 925 | let index_path = PathBuf::from("missing_index_file.json"); 926 | let read = read_index_json(&index_path)?; 927 | assert_eq!(read, None); 928 | Ok(()) 929 | } 930 | 931 | #[test] 932 | fn test_extract_game_name() { 933 | let code = r#" 934 | Dim tableheight: tableheight = Table1.height 935 | 936 | Const cGameName="godzilla",UseSolenoids=2,UseLamps=1,UseGI=0, SCoin="" 937 | Const UseVPMModSol = True 938 | 939 | "# 940 | .to_string(); 941 | let game_name = extract_game_name(code); 942 | assert_eq!(game_name, Some("godzilla".to_string())); 943 | } 944 | 945 | #[test] 946 | fn test_extract_game_name_commented() { 947 | let code = r#" 948 | 'Const cGameName = "commented" 949 | Const cGameName = "actual" 950 | "# 951 | .to_string(); 952 | let game_name = extract_game_name(code); 953 | assert_eq!(game_name, Some("actual".to_string())); 954 | } 955 | 956 | #[test] 957 | fn test_extract_game_name_spaced() { 958 | let code = r#" 959 | Const cGameName = "gg" 960 | "# 961 | .to_string(); 962 | let game_name = extract_game_name(code); 963 | assert_eq!(game_name, Some("gg".to_string())); 964 | } 965 | 966 | #[test] 967 | fn test_extract_game_name_casing() { 968 | let code = r#" 969 | const cgamenamE = "othercase" 970 | "# 971 | .to_string(); 972 | let game_name = extract_game_name(code); 973 | assert_eq!(game_name, Some("othercase".to_string())); 974 | } 975 | 976 | #[test] 977 | fn test_extract_game_name_uppercase_name() { 978 | let code = r#" 979 | Const cGameName = "BOOM" 980 | "# 981 | .to_string(); 982 | let game_name = extract_game_name(code); 983 | assert_eq!(game_name, Some("BOOM".to_string())); 984 | } 985 | 986 | #[test] 987 | fn test_extract_game_name_with_underscore() { 988 | let code = r#" 989 | Const cGameName="simp_a27",UseSolenoids=1,UseLamps=0,UseGI=0,SSolenoidOn="SolOn",SSolenoidOff="SolOff", SCoin="coin" 990 | 991 | LoadVPM "01000200", "DE.VBS", 3.36 992 | "# 993 | .to_string(); 994 | let game_name = extract_game_name(code); 995 | assert_eq!(game_name, Some("simp_a27".to_string())); 996 | } 997 | 998 | #[test] 999 | fn test_extract_game_name_multidef_end() { 1000 | let code = r#" 1001 | Const UseSolenoids=2,UseLamps=0,UseSync=1,UseGI=0,SCoin="coin",cGameName="barbwire" 1002 | "# 1003 | .to_string(); 1004 | let game_name = extract_game_name(code); 1005 | assert_eq!(game_name, Some("barbwire".to_string())); 1006 | } 1007 | 1008 | /// https://github.com/francisdb/vpxtool/issues/203 1009 | #[test] 1010 | fn test_extract_game_name_in_controller() { 1011 | let code = r#" 1012 | Sub Gorgar_Init 1013 | LoadLUT 1014 | On Error Resume Next 1015 | With Controller 1016 | .GameName="grgar_l1" 1017 | "#; 1018 | let game_name = extract_game_name(code); 1019 | assert_eq!(game_name, Some("grgar_l1".to_string())); 1020 | } 1021 | 1022 | #[test] 1023 | fn test_extract_game_name_2_line_dim() { 1024 | let code = r#" 1025 | Dim cGameName 1026 | cGameName = "abv106" 1027 | "# 1028 | .to_string(); 1029 | let game_name = extract_game_name(code); 1030 | assert_eq!(game_name, Some("abv106".to_string())); 1031 | } 1032 | 1033 | #[test] 1034 | fn test_requires_pinmame() { 1035 | let code = r#"# 1036 | LoadVPM "01210000", "sys80.VBS", 3.1 1037 | "# 1038 | .to_string(); 1039 | assert!(requires_pinmame(code)); 1040 | } 1041 | 1042 | #[test] 1043 | fn test_requires_pinmame_other_casing() { 1044 | let code = r#" 1045 | loadVpm "01210000", \"sys80.VBS\", 3.1 1046 | "# 1047 | .to_string(); 1048 | assert!(requires_pinmame(code)); 1049 | } 1050 | 1051 | #[test] 1052 | fn test_requires_pinmame_not() { 1053 | let code = r#" 1054 | Const cGameName = "GTB_4Square_1971" 1055 | "# 1056 | .to_string(); 1057 | assert!(!requires_pinmame(code)); 1058 | } 1059 | 1060 | #[test] 1061 | fn test_requires_pinmame_with_same_sub() { 1062 | // got this from blood machines 1063 | let code = r#" 1064 | Sub LoadVPM(VPMver, VBSfile, VBSver) 1065 | LoadVBSFiles VPMver, VBSfile, VBSver 1066 | LoadController("VPM") 1067 | End Sub 1068 | "# 1069 | .to_string(); 1070 | assert!(!requires_pinmame(code)); 1071 | } 1072 | 1073 | #[test] 1074 | fn test_requires_pinmame_comment() { 1075 | // got this from blood machines 1076 | let code = r#" 1077 | ' VRRoom set based on RenderingMode 1078 | ' Internal DMD in Desktop Mode, using a textbox (must be called before LoadVPM) 1079 | Dim UseVPMDMD, VRRoom, DesktopMode 1080 | If RenderingMode = 2 Then VRRoom = VRRoomChoice Else VRRoom = 0 1081 | "# 1082 | .to_string(); 1083 | assert!(!requires_pinmame(code)); 1084 | } 1085 | 1086 | #[test] 1087 | fn test_requires_pinmame_comment_and_used() { 1088 | // got this from blood machines 1089 | let code = r#" 1090 | Const SCoin="coin3",cCredits="" 1091 | 1092 | LoadVPM "01210000","sys80.vbs",3.10 1093 | 1094 | 'Sub LoadVPM(VPMver, VBSfile, VBSver) 1095 | ' On Error Resume Next 1096 | "# 1097 | .to_string(); 1098 | assert!(requires_pinmame(code)); 1099 | } 1100 | 1101 | #[test] 1102 | fn test_requires_pinmame_cr_only_lines_and_commented_sub_loadvpm() { 1103 | // This code was taken from "Monte Carlo (Premier 1987) (10.7) 1.6.vpx" 1104 | let code = 1105 | "LoadVPM \"01210000\", \"sys80.VBS\", 3.1\r\r'Sub LoadVPM(VPMver, VBSfile, VBSver)\r"; 1106 | assert!(requires_pinmame(code)); 1107 | } 1108 | 1109 | #[cfg(target_os = "linux")] 1110 | #[test] 1111 | fn test_find_local_rom_path_relative_linux() { 1112 | // On Batocera the PinMAMEPath is configured as ./ 1113 | // That gives us a roms path of ./roms 1114 | let test_table_dir = testdir!(); 1115 | let vpx_path = test_table_dir.join("test.vpx"); 1116 | let expected_rom_path = test_table_dir.join("roms").join("testgamename.zip"); 1117 | fs::create_dir_all(expected_rom_path.parent().unwrap()).unwrap(); 1118 | File::create(&expected_rom_path).unwrap(); 1119 | 1120 | let local_rom = find_local_rom_path( 1121 | &vpx_path, 1122 | &Some("testgamename".to_string()), 1123 | Some(&PathBuf::from("./roms")), 1124 | ) 1125 | .unwrap(); 1126 | assert_eq!(local_rom, Some(expected_rom_path)); 1127 | } 1128 | 1129 | #[cfg(target_os = "linux")] 1130 | #[test] 1131 | fn test_find_local_rom_path_relative_not_found_linux() { 1132 | // On Batocera the PinMAMEPath is configured as ./ 1133 | // That gives us a roms path of ./roms 1134 | let test_table_dir = testdir!(); 1135 | let vpx_path = test_table_dir.join("test.vpx"); 1136 | let expected_rom_path = test_table_dir.join("roms").join("testgamename.zip"); 1137 | fs::create_dir_all(expected_rom_path.parent().unwrap()).unwrap(); 1138 | 1139 | let local_rom = find_local_rom_path( 1140 | &vpx_path, 1141 | &Some("testgamename".to_string()), 1142 | Some(&PathBuf::from("./roms")), 1143 | ) 1144 | .unwrap(); 1145 | assert_eq!(local_rom, None); 1146 | } 1147 | } 1148 | --------------------------------------------------------------------------------