├── 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 | 
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