diff --git a/Cargo.lock b/Cargo.lock index cc643d0..8c619ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,10 +100,10 @@ checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "const-random", - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -1040,7 +1040,7 @@ dependencies = [ "derive_more", "glam", "itertools 0.13.0", - "rand", + "rand 0.8.5", "rand_distr", "serde", "smallvec", @@ -1427,7 +1427,7 @@ checksum = "4f01088c048960ea50ee847c3f668942ecf49ed26be12a1585a5e59b6a941d9a" dependencies = [ "ahash", "bevy_utils_proc_macros", - "getrandom", + "getrandom 0.2.15", "hashbrown 0.14.5", "thread_local", "tracing", @@ -1982,7 +1982,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom", + "getrandom 0.2.15", "once_cell", "tiny-keccak", ] @@ -2633,7 +2633,7 @@ checksum = "aef603df4ba9adbca6a332db7da6f614f21eafefbaf8e087844e452fdec152d0" dependencies = [ "deunicode", "dummy", - "rand", + "rand 0.8.5", ] [[package]] @@ -2839,10 +2839,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", +] + [[package]] name = "gif" version = "0.13.1" @@ -2931,7 +2943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc46dd3ec48fdd8e693a98d2b8bafae273a2d54c1de02a2a7e3d57d501f39677" dependencies = [ "bytemuck", - "rand", + "rand 0.8.5", "serde", ] @@ -4519,7 +4531,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -4654,8 +4666,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.0", + "zerocopy 0.8.16", ] [[package]] @@ -4665,7 +4688,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.0", ] [[package]] @@ -4674,7 +4707,17 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff" +dependencies = [ + "getrandom 0.3.1", + "zerocopy 0.8.16", ] [[package]] @@ -4684,7 +4727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -4725,8 +4768,8 @@ dependencies = [ "once_cell", "paste", "profiling", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "simd_helpers", "system-deps", "thiserror 1.0.69", @@ -4815,7 +4858,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.15", "libredox", "thiserror 1.0.69", ] @@ -4826,7 +4869,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ - "getrandom", + "getrandom 0.2.15", "libredox", "thiserror 2.0.11", ] @@ -5379,7 +5422,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand", - "getrandom", + "getrandom 0.2.15", "once_cell", "rustix", "windows-sys 0.59.0", @@ -5815,7 +5858,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" dependencies = [ - "getrandom", + "getrandom 0.2.15", "serde", ] @@ -5891,11 +5934,13 @@ dependencies = [ "dialoguer", "edit", "git-version", + "image", "indicatif", "is_executable", "jojodiff", "pinmame-nvram", "pretty_assertions", + "rand 0.9.0", "regex", "serde", "serde_json", @@ -5956,6 +6001,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -6909,6 +6963,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.8.0", +] + [[package]] name = "write16" version = "1.0.0" @@ -7033,7 +7096,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b8c07a70861ce02bad1607b5753ecb2501f67847b9f9ada7c160fff0ec6300c" +dependencies = [ + "zerocopy-derive 0.8.16", ] [[package]] @@ -7047,6 +7119,17 @@ dependencies = [ "syn", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5226bc9a9a9836e7428936cde76bb6b22feea1a8bfdbc0d241136e4d13417e25" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.5" diff --git a/vpxtool_cli/Cargo.toml b/vpxtool_cli/Cargo.toml index 2de712b..5d9a29e 100644 --- a/vpxtool_cli/Cargo.toml +++ b/vpxtool_cli/Cargo.toml @@ -34,6 +34,8 @@ vpin = { version = "0.17.3" } edit = "0.1.5" pinmame-nvram = "0.3.11" +image = "0.25.5" [dev-dependencies] pretty_assertions = "1.4.1" +rand = "0.9.0" diff --git a/vpxtool_cli/assets b/vpxtool_cli/assets deleted file mode 120000 index fcbd700..0000000 --- a/vpxtool_cli/assets +++ /dev/null @@ -1 +0,0 @@ -../vpxgui/assets \ No newline at end of file diff --git a/vpxtool_cli/src/backglass.rs b/vpxtool_cli/src/backglass.rs new file mode 100644 index 0000000..96d1016 --- /dev/null +++ b/vpxtool_cli/src/backglass.rs @@ -0,0 +1,498 @@ +use image::{DynamicImage, Rgba, RgbaImage}; +use std::io; + +#[derive(Debug, PartialEq, Copy, Clone)] +pub(crate) struct Vec2 { + pub(crate) x: u32, + pub(crate) y: u32, +} + +impl Vec2 { + pub fn new(x: u32, y: u32) -> Vec2 { + Vec2 { x, y } + } +} + +/// relative location of the hole in the image +#[derive(Debug, PartialEq)] +pub(crate) struct DMDHole { + pub(crate) pos: Vec2, + pub(crate) dim: Vec2, + pub(crate) parent_dim: Vec2, +} + +impl DMDHole { + pub(crate) fn new( + x1: u32, + y1: u32, + x2: u32, + y2: u32, + parent_width: u32, + parent_height: u32, + ) -> DMDHole { + DMDHole { + pos: Vec2 { x: x1, y: y1 }, + dim: Vec2 { + x: x2 - x1 + 1, + y: y2 - y1 + 1, + }, + parent_dim: Vec2 { + x: parent_width, + y: parent_height, + }, + } + } + + pub fn width(&self) -> u32 { + self.dim.x + } + + pub fn height(&self) -> u32 { + self.dim.y + } + + pub fn x(&self) -> u32 { + self.pos.x + } + + pub fn y(&self) -> u32 { + self.pos.y + } + + #[allow(dead_code)] + pub fn parent_width(&self) -> u32 { + self.parent_dim.x + } + + #[allow(dead_code)] + pub fn parent_height(&self) -> u32 { + self.parent_dim.y + } + + pub fn scale_to_parent(&self, width: u32, height: u32) -> DMDHole { + let x = (self.pos.x as f32 / self.parent_dim.x as f32 * width as f32) as u32; + let y = (self.pos.y as f32 / self.parent_dim.y as f32 * height as f32) as u32; + let dim_x = (self.dim.x as f32 / self.parent_dim.x as f32 * width as f32) as u32; + let dim_y = (self.dim.y as f32 / self.parent_dim.y as f32 * height as f32) as u32; + DMDHole { + pos: Vec2 { x, y }, + dim: Vec2 { x: dim_x, y: dim_y }, + parent_dim: Vec2 { + x: width, + y: height, + }, + } + } +} + +/// Finds the dmd hole in the image by using the following algorithm: +/// 1. Split the image in divisions*divisions parts +/// 2. For each part, calculate get the center of the part +/// 3. For each part, get the color of the center of the part +/// 4. For each part, trace a line from the center to the edge of the image in all four directions +/// 5. When the color changes (with a threshold), we have found the borders of that part +/// 6. Pick the largest hole we found (there might be duplicates) +/// 7. Only return the hole if it is wider than min_width% of the image +/// +/// Returns the hole in the image if found +/// +/// # Arguments +/// +/// * `image` - The image to find the hole in +/// * `divisions` - The number of divisions to split the image in +/// * `min_width` - The minimum width of the hole as a percentage of the image width +/// * `max_deviation_u8` - The maximum deviation in color to consider a color change +/// +pub(crate) fn find_hole( + image: &DynamicImage, + divisions: u8, + min_width: u32, + max_deviation_u8: u8, +) -> io::Result> { + // TODO we could optimize this by skipping a part if is contained in the largest hole we found so far + let image_width = image.width(); + let image_height = image.height(); + let mut max_hole: Option = None; + for x in 0..divisions { + for y in 0..divisions { + let x1 = (x as f32 / divisions as f32) * image_width as f32; + let y1 = (y as f32 / divisions as f32) * image_height as f32; + let x2 = ((x + 1) as f32 / divisions as f32) * image_width as f32; + let y2 = ((y + 1) as f32 / divisions as f32) * image_height as f32; + let center_x = ((x1 + x2) / 2.0) as u32; + let center_y = ((y1 + y2) / 2.0) as u32; + + let hole = find_hole_from(image, center_x, center_y, max_deviation_u8)?; + + if hole.width() > min_width { + if let Some(old_max_hole) = &max_hole { + if hole.width() * hole.height() > old_max_hole.width() * old_max_hole.height() { + max_hole = Some(hole); + } + } else { + max_hole = Some(hole); + } + } + } + } + + Ok(max_hole) +} + +fn find_hole_from( + image: &DynamicImage, + center_x: u32, + center_y: u32, + max_deviation_u8: u8, +) -> io::Result { + let center: Vec2 = Vec2 { + x: center_x, + y: center_y, + }; + let image_width = image.width(); + let image_height = image.height(); + let rgba_image = match image.as_rgba8() { + Some(rgba_image) => rgba_image, + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Image is not in RGBA format", + )); + } + }; + let center_color = rgba_image.get_pixel(center_x, center_y); + + let mut left = center_x; + while left > 0 { + left -= 1; + let color_x = rgba_image.get_pixel(left, center_y); + if !color_within_deviation(center_color, color_x, max_deviation_u8) { + left += 1; + break; + } + } + let mut right = center_x; + while right < image_width - 1 { + right += 1; + let color_x = rgba_image.get_pixel(right, center_y); + if !color_within_deviation(center_color, color_x, max_deviation_u8) { + right -= 1; + break; + } + } + let mut top = center_y; + while top > 0 { + top -= 1; + let color_y = rgba_image.get_pixel(center_x, top); + if !color_within_deviation(center_color, color_y, max_deviation_u8) { + top += 1; + break; + } + } + let mut bottom = center_y; + while bottom < image_height - 1 { + bottom += 1; + let color_y = rgba_image.get_pixel(center_x, bottom); + if !color_within_deviation(center_color, color_y, max_deviation_u8) { + bottom -= 1; + break; + } + } + + // Now we do an outward from the center toward the corners check to account for + // shamfered/filleted corners and shrink the hole accordingly. + + let top_left = trace_line( + rgba_image, + center, + Vec2::new(left, top), + center_color, + max_deviation_u8, + ); + let top_right = trace_line( + rgba_image, + center, + Vec2::new(right, top), + center_color, + max_deviation_u8, + ); + let bottom_left = trace_line( + rgba_image, + center, + Vec2::new(left, bottom), + center_color, + max_deviation_u8, + ); + + let bottom_right = trace_line( + rgba_image, + center, + Vec2::new(right, bottom), + center_color, + max_deviation_u8, + ); + + let left = top_left.x.max(bottom_left.x); + let right = top_right.x.min(bottom_right.x); + let top = top_left.y.max(top_right.y); + let bottom = bottom_left.y.min(bottom_right.y); + + let hole = DMDHole::new(left, top, right, bottom, image_width, image_height); + Ok(hole) +} + +fn trace_line( + rgba_image: &RgbaImage, + start: Vec2, + end: Vec2, + color: &Rgba, + max_deviation_u8: u8, +) -> Vec2 { + let mut current = end; + for point in LinePixelIterator::new(start, end) { + let current_color = rgba_image.get_pixel(point.x, point.y); + if !color_within_deviation(current_color, color, max_deviation_u8) { + break; + } + current = point; + } + current +} + +struct LinePixelIterator { + x0: i32, + y0: i32, + x1: i32, + y1: i32, + dx: i32, + dy: i32, + sx: i32, + sy: i32, + err: i32, + done: bool, +} + +impl LinePixelIterator { + fn new(from: Vec2, to: Vec2) -> Self { + let x0 = from.x as i32; + let y0 = from.y as i32; + let x1 = to.x as i32; + let y1 = to.y as i32; + let dx = (x1 - x0).abs(); + let dy = (y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let err = dx - dy; + LinePixelIterator { + x0, + y0, + x1, + y1, + dx, + dy, + sx, + sy, + err, + done: false, + } + } +} + +impl Iterator for LinePixelIterator { + type Item = Vec2; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + + let point = Vec2 { + x: self.x0 as u32, + y: self.y0 as u32, + }; + + if self.x0 == self.x1 && self.y0 == self.y1 { + self.done = true; + } else { + let e2 = 2 * self.err; + if e2 > -self.dy { + self.err -= self.dy; + self.x0 += self.sx; + } + if e2 < self.dx { + self.err += self.dx; + self.y0 += self.sy; + } + } + + Some(point) + } +} + +fn color_within_deviation(c1: &image::Rgba, c2: &image::Rgba, max_deviation: u8) -> bool { + let diff = |a: u8, b: u8| a.abs_diff(b) as u32; + let total_deviation: u32 = + c1.0.iter() + .zip(c2.0.iter()) + .map(|(a, b)| diff(*a, *b)) + .sum(); + total_deviation <= max_deviation as u32 * 4 +} + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbaImage; + use pretty_assertions::assert_eq; + use rand::Rng; + + #[test] + fn test_find_hole_from() { + let image_width = 20; + let image_height = 16; + let mut image = noise_image(image_width, image_height); + clear_square( + &mut image, + 5, + 4, + 10, + 8, + image::Rgba([0xFF, 0xAA, 0x22, 255]), + ); + let dynamic_image = DynamicImage::ImageRgba8(image); + + let hole = find_hole_from(&dynamic_image, image_width / 2, image_height / 2, 0).unwrap(); + let expected = DMDHole::new(5, 4, 14, 11, image_width, image_height); + assert_eq!(hole, expected); + } + + #[test] + fn test_find_hole_from_with_inward_corners() { + // we create an image with a cross like hole to force the algorithm to find the inward corners + let image_width = 100; + let image_height = 100; + let mut image = noise_image(image_width, image_height); + let black = image::Rgba([0x00, 0x00, 0x00, 255]); + clear_square(&mut image, 10, 20, 80, 60, black); + clear_square(&mut image, 20, 10, 60, 80, black); + let dynamic_image = DynamicImage::ImageRgba8(image); + + // write image to disk + //dynamic_image.save("test_find_hole_from.png").unwrap(); + + let hole = find_hole_from(&dynamic_image, image_width / 2, image_height / 2, 0).unwrap(); + assert_eq!(hole.width(), 60); + assert_eq!(hole.height(), 60); + assert_eq!(hole.x(), 20); + assert_eq!(hole.y(), 20); + } + + #[test] + fn test_find_hole() { + let image_width = 320; + let image_height = 200; + let mut image = noise_image(image_width, image_height); + clear_square( + &mut image, + 100, + 50, + 100, + 50, + image::Rgba([0xFF, 0xAA, 0x22, 255]), + ); + let dynamic_image = DynamicImage::ImageRgba8(image); + + let hole = find_hole(&dynamic_image, 10, 50, 1).unwrap(); + let expected = Some(DMDHole::new(100, 50, 199, 99, image_width, image_height)); + assert_eq!(hole, expected); + } + + #[test] + fn test_find_hole_no_hole() { + let width = 320; + let height = 200; + let image = noise_image(width, height); + let dynamic_image = DynamicImage::ImageRgba8(image); + + let hole = find_hole(&dynamic_image, 10, 10, 1).unwrap(); + assert_eq!(hole, None); + } + + #[test] + fn test_find_whole_image_with_deviation_max() { + let image_width = 320; + let image_height = 200; + let image = noise_image(image_width, image_height); + let dynamic_image = DynamicImage::ImageRgba8(image); + + let hole = find_hole(&dynamic_image, 10, 100, 255).unwrap(); + let expected = Some(DMDHole::new( + 0, + 0, + image_width - 1, + image_height - 1, + image_width, + image_height, + )); + assert_eq!(hole, expected); + } + + #[test] + fn test_dmd_hole_1_x_1() { + let hole = DMDHole::new(0, 0, 0, 0, 1, 1); + assert_eq!(hole.width(), 1); + assert_eq!(hole.height(), 1); + assert_eq!(hole.x(), 0); + assert_eq!(hole.y(), 0); + } + + #[test] + fn test_dmd_hole_scale_1_x_1_to_parent() { + let hole = DMDHole::new(0, 0, 1, 1, 2, 2); + let scaled_hole = hole.scale_to_parent(4, 4); + assert_eq!(scaled_hole.width(), 4); + assert_eq!(scaled_hole.height(), 4); + assert_eq!(scaled_hole.x(), 0); + assert_eq!(scaled_hole.y(), 0); + } + + #[test] + fn test_dmd_hole_scale_to_parent() { + let hole = DMDHole::new(8, 8, 21, 21, 30, 30); + let scaled_hole = hole.scale_to_parent(20, 20); + assert_eq!(scaled_hole.width(), 9); + assert_eq!(scaled_hole.height(), 9); + assert_eq!(scaled_hole.x(), 5); + assert_eq!(scaled_hole.y(), 5); + assert_eq!(scaled_hole.parent_width(), 20); + assert_eq!(scaled_hole.parent_height(), 20); + } + + fn noise_image(width: u32, height: u32) -> RgbaImage { + let dynamic_image = DynamicImage::new_rgba8(width, height); + let mut image = dynamic_image.to_rgba8(); + let mut rng = rand::rng(); + for x in 0..width { + for y in 0..height { + let random_color = image::Rgba([rng.random(), rng.random(), rng.random(), 255]); + image.put_pixel(x, y, random_color); + } + } + image + } + + fn clear_square( + image: &mut RgbaImage, + x1: u32, + y1: u32, + width: u32, + height: u32, + color: image::Rgba, + ) { + for x in x1..x1 + width { + for y in y1..y1 + height { + image.put_pixel(x, y, color); + } + } + } +} diff --git a/vpxtool_cli/src/frontend.rs b/vpxtool_cli/src/frontend.rs index 72e3bf4..0690b3d 100644 --- a/vpxtool_cli/src/frontend.rs +++ b/vpxtool_cli/src/frontend.rs @@ -1,10 +1,12 @@ +use crate::backglass::find_hole; use crate::patcher::LineEndingsResult::{NoChanges, Unified}; use crate::patcher::{patch_vbs_file, unify_line_endings_vbs_file}; use crate::{ - confirm, info_diff, info_edit, info_gather, open_editor, run_diff, script_diff, + confirm, info_diff, info_edit, info_gather, open_editor, run_diff, script_diff, strip_cr_lf, vpx::{extractvbs, vbs_path_for, ExtractResult}, DiffColor, ProgressBarProgress, }; +use base64::Engine; use colored::Colorize; use console::Emoji; use dialoguer::theme::ColorfulTheme; @@ -13,6 +15,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use is_executable::IsExecutable; use pinmame_nvram::dips::{get_all_dip_switches, set_dip_switches}; use std::fs::OpenOptions; +use std::io::BufReader; use std::{ fs::File, io, @@ -23,6 +26,7 @@ use std::{ use vpxtool_shared::config::ResolvedConfig; use vpxtool_shared::indexer; use vpxtool_shared::indexer::{IndexError, IndexedTable, Progress}; +use vpxtool_shared::vpinball_config::{VPinballConfig, WindowInfo, WindowType}; const LAUNCH: Emoji = Emoji("🚀", "[launch]"); const CRASH: Emoji = Emoji("💥", "[crash]"); @@ -48,10 +52,11 @@ enum TableOption { CreateVBSPatch, DIPSwitches, NVRAMClear, + B2SAutoPositionDMD, } impl TableOption { - const ALL: [TableOption; 15] = [ + const ALL: [TableOption; 16] = [ TableOption::Launch, TableOption::LaunchFullscreen, TableOption::LaunchWindowed, @@ -67,6 +72,7 @@ impl TableOption { TableOption::CreateVBSPatch, TableOption::DIPSwitches, TableOption::NVRAMClear, + TableOption::B2SAutoPositionDMD, ]; fn from_index(index: usize) -> Option { @@ -86,6 +92,7 @@ impl TableOption { 12 => Some(TableOption::CreateVBSPatch), 13 => Some(TableOption::DIPSwitches), 14 => Some(TableOption::NVRAMClear), + 15 => Some(TableOption::B2SAutoPositionDMD), _ => None, } } @@ -107,6 +114,7 @@ impl TableOption { TableOption::CreateVBSPatch => "VBScript > Create patch file".to_string(), TableOption::DIPSwitches => "DIP Switches".to_string(), TableOption::NVRAMClear => "NVRAM > Clear".to_string(), + TableOption::B2SAutoPositionDMD => "Backglass > Auto-position DMD".to_string(), } } } @@ -265,7 +273,7 @@ fn table_menu( } Err(err) => { let msg = format!("Unable to reload tables: {:?}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } @@ -283,30 +291,30 @@ fn table_menu( } Err(err) => { let msg = format!("Unable to edit VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } Some(TableOption::ExtractVBS) => match extractvbs(selected_path, None, false) { Ok(ExtractResult::Extracted(path)) => { - prompt(format!("VBS extracted to {}", path.to_string_lossy())); + prompt(&format!("VBS extracted to {}", path.to_string_lossy())); } Ok(ExtractResult::Existed(path)) => { let msg = format!("VBS already exists at {}", path.to_string_lossy()); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } Err(err) => { let msg = format!("Unable to extract VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } }, Some(TableOption::ShowVBSDiff) => match script_diff(selected_path) { Ok(diff) => { - prompt(diff); + prompt(&diff); } Err(err) => { let msg = format!("Unable to diff VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } }, Some(TableOption::PatchVBS) => { @@ -315,19 +323,19 @@ fn table_menu( Ok(ExtractResult::Extracted(path)) => path, Err(err) => { let msg = format!("Unable to extract VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); return; } }; match patch_vbs_file(&vbs_path) { Ok(applied) => { if applied.is_empty() { - prompt("No patches applied.".to_string()); + prompt("No patches applied."); } else { applied.iter().for_each(|patch| { println!("Applied patch: {}", patch); }); - prompt(format!( + prompt(&format!( "Patched VBS file at {}", vbs_path.to_string_lossy() )); @@ -335,7 +343,7 @@ fn table_menu( } Err(err) => { let msg = format!("Unable to patch VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } @@ -346,23 +354,23 @@ fn table_menu( Ok(ExtractResult::Extracted(path)) => path, Err(err) => { let msg = format!("Unable to extract VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); return; } }; match unify_line_endings_vbs_file(&vbs_path) { Ok(NoChanges) => { - prompt("No changes applied as file has correct line endings".to_string()); + prompt("No changes applied as file has correct line endings"); } Ok(Unified) => { - prompt(format!( + prompt(&format!( "Unified line endings in VBS file at {}", vbs_path.to_string_lossy() )); } Err(err) => { let msg = format!("Unable to patch VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } @@ -373,7 +381,7 @@ fn table_menu( Ok(ExtractResult::Extracted(path)) => path, Err(err) => { let msg = format!("Unable to extract VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); return; } }; @@ -387,17 +395,17 @@ fn table_menu( } Err(err) => { let msg = format!("Unable to diff VBS: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } Some(TableOption::InfoShow) => match info_gather(selected_path) { Ok(info) => { - prompt(info); + prompt(&info); } Err(err) => { let msg = format!("Unable to gather table info: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } }, Some(TableOption::InfoEdit) => match info_edit(selected_path, Some(config)) { @@ -406,16 +414,16 @@ fn table_menu( } Err(err) => { let msg = format!("Unable to edit table info: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt_error(&msg); } }, Some(TableOption::InfoDiff) => match info_diff(selected_path) { Ok(diff) => { - prompt(diff); + prompt(&diff); } Err(err) => { let msg = format!("Unable to diff info: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt_error(&msg); } }, Some(TableOption::DIPSwitches) => { @@ -429,26 +437,130 @@ fn table_menu( } Err(err) => { let msg = format!("Unable to edit DIP switches: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt_error(&msg); } } } else { - prompt( - "This table does not have an NVRAM file, try launching it once." - .to_string(), - ); + prompt("This table does not have an NVRAM file, try launching it once."); } } else { - prompt("This table is not using used PinMAME".to_string()); + prompt("This table is not using used PinMAME"); } } Some(TableOption::NVRAMClear) => { clear_nvram(info); } + Some(TableOption::B2SAutoPositionDMD) => match auto_position_dmd(config, &info) { + Ok(msg) => { + prompt(&msg); + } + Err(err) => { + let msg = format!("Unable to auto-position DMD: {}", err); + prompt_error(&msg); + } + }, None => (), } } +fn auto_position_dmd(config: &ResolvedConfig, info: &&IndexedTable) -> Result { + match &info.b2s_path { + Some(b2s_path) => { + // TODO move image reading parsing code to vpin + let reader = + BufReader::new(File::open(b2s_path).map_err(|e| { + format!("Unable to open B2S file {}: {}", b2s_path.display(), e) + })?); + let b2s = vpin::directb2s::read(reader) + .map_err(|e| format!("Unable to read B2S file: {}", e))?; + + if let Some(dmd_image) = b2s.images.dmd_image { + // load vpinball config + + let ini_file = config.vpinball_ini_file(); + if ini_file.exists() { + let base64data_with_cr_lf = dmd_image.value; + let base64data = strip_cr_lf(&base64data_with_cr_lf); + let decoded_data = base64::engine::general_purpose::STANDARD + .decode(base64data) + .map_err(|e| format!("Unable to decode base64 data: {}", e))?; + // read the image with image crate + let image = image::load_from_memory(&decoded_data) + .map_err(|e| format!("Unable to read DMD image: {}", e))?; + let hole_opt = find_hole(&image, 6, &image.width() / 2, 5) + .map_err(|e| format!("Unable to find hole in DMD image: {}", e))?; + if let Some(hole) = hole_opt { + let table_ini_path = info.path.with_extension("ini"); + let vpinball_config = VPinballConfig::read(&ini_file) + .map_err(|e| format!("Unable to read vpinball ini file: {}", e))?; + let mut table_config = if table_ini_path.exists() { + VPinballConfig::read(&table_ini_path) + .map_err(|e| format!("Unable to read table ini file: {}", e)) + } else { + Ok(VPinballConfig::default()) + }?; + + let window_info = table_config + .get_window_info(WindowType::B2SDMD) + .or(vpinball_config.get_window_info(WindowType::B2SDMD)); + + if let Some(WindowInfo { + x: Some(x), + y: Some(y), + width: Some(width), + height: Some(height), + .. + }) = window_info + { + // Scale and position the hole to the vpinball FullDMD size. + // We might want to preserve the aspect ratio. + let hole = hole.scale_to_parent(width, height); + + let dmd_x = x + hole.x(); + let dmd_y = y + hole.y(); + if hole.width() < 10 || hole.height() < 10 { + return Err( + "Detected hole is too small, unable to update".to_string() + ); + } + table_config.set_window_position(WindowType::PinMAME, dmd_x, dmd_y); + table_config.set_window_size( + WindowType::PinMAME, + hole.width(), + hole.height(), + ); + table_config.set_window_position(WindowType::FlexDMD, dmd_x, dmd_y); + table_config.set_window_size( + WindowType::FlexDMD, + hole.width(), + hole.height(), + ); + table_config.write(&table_ini_path).unwrap(); + Ok(format!( + "DMD window dimensions an position in {} updated to {}x{} at {},{}", + table_ini_path.file_name().unwrap().to_string_lossy(), + hole.width(), + hole.height(), + dmd_x, + dmd_y + )) + } else { + Err("Unable to find B2SDMD window or dimensions not specified in vpinball ini file".to_string()) + } + } else { + Err("Unable to find hole in DMD image".to_string()) + } + } else { + Err("Unable to read vpinball ini file".to_string()) + } + } else { + Err("This table does not have a DMD image".to_string()) + } + } + None => Err("This table does not have a B2S file".to_string()), + } +} + fn edit_dip_switches(nvram: PathBuf) -> io::Result<()> { let mut nvram_file = OpenOptions::new().read(true).write(true).open(nvram)?; let mut switches = get_all_dip_switches(&mut nvram_file)?; @@ -478,7 +590,7 @@ fn edit_dip_switches(nvram: PathBuf) -> io::Result<()> { }); set_dip_switches(&mut nvram_file, &switches)?; - prompt("DIP switches updated".to_string()); + prompt("DIP switches updated"); } Ok(()) } @@ -495,33 +607,33 @@ fn clear_nvram(info: &IndexedTable) { Ok(true) => { match std::fs::remove_file(&nvram_file) { Ok(_) => { - prompt(format!("NVRAM file {} removed", nvram_file.display())); + prompt(&format!("NVRAM file {} removed", nvram_file.display())); } Err(err) => { let msg = format!("Unable to remove NVRAM file: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } Ok(false) => { - prompt("NVRAM file removal canceled.".to_string()); + prompt("NVRAM file removal canceled."); } Err(err) => { let msg = format!("Error during confirmation: {}", err); - prompt(msg.truecolor(255, 125, 0).to_string()); + prompt(&msg.truecolor(255, 125, 0).to_string()); } } } else { - prompt(format!( + prompt(&format!( "NVRAM file {} does not exist", nvram_file.display() )); } } else { - prompt("This table does not have an NVRAM file".to_string()); + prompt("This table does not have an NVRAM file"); } } else { - prompt("This table is not using used PinMAME".to_string()); + prompt("This table is not using used PinMAME"); } } @@ -537,15 +649,19 @@ fn nvram_for_rom(info: &IndexedTable) -> Option { }) } -fn prompt>(msg: S) { +fn prompt(msg: &str) { Input::::new() - .with_prompt(format!("{} - Press enter to continue.", msg.into())) + .with_prompt(format!("{} - Press enter to continue.", msg)) .default("".to_string()) .show_default(false) .interact() .unwrap(); } +fn prompt_error(msg: &str) { + prompt(&msg.truecolor(255, 125, 0).to_string()); +} + fn choose_table_option(table_name: &str) -> Option { // iterate over table options let selections = TableOption::ALL @@ -579,13 +695,13 @@ fn launch(selected_path: &PathBuf, vpinball_executable: &Path, fullscreen: Optio //println!("Table exited normally"); } Some(11) => { - prompt(format!("{} Visual Pinball exited with segfault, you might want to report this to the vpinball team.", CRASH)); + prompt(&format!("{} Visual Pinball exited with segfault, you might want to report this to the vpinball team.", CRASH)); } Some(139) => { - prompt(format!("{} Visual Pinball exited with segfault, you might want to report this to the vpinball team.", CRASH)); + prompt(&format!("{} Visual Pinball exited with segfault, you might want to report this to the vpinball team.", CRASH)); } Some(code) => { - prompt(format!( + prompt(&format!( "{} Visual Pinball exited with code {}", CRASH, code )); diff --git a/vpxtool_cli/src/lib.rs b/vpxtool_cli/src/lib.rs index 95c5db6..0cbcf04 100644 --- a/vpxtool_cli/src/lib.rs +++ b/vpxtool_cli/src/lib.rs @@ -29,6 +29,7 @@ use vpxtool_shared::config::{ResolvedConfig, SetupConfigResult}; use vpxtool_shared::indexer::{IndexError, Progress}; use vpxtool_shared::{config, indexer}; +mod backglass; pub mod fixprint; mod frontend; pub mod patcher; @@ -1134,7 +1135,7 @@ fn extract_directb2s(expanded_path: &PathBuf) -> io::Result<()> { let mut root_dir = std::fs::DirBuilder::new(); root_dir.recursive(true); - root_dir.create(&root_dir_path).unwrap(); + root_dir.create(&root_dir_path)?; println!("Writing to {}", root_dir_path.display())?; wite_images(b2s, root_dir_path.as_path()); diff --git a/vpxtool_shared/src/indexer.rs b/vpxtool_shared/src/indexer.rs index 3b8e3e3..1361902 100644 --- a/vpxtool_shared/src/indexer.rs +++ b/vpxtool_shared/src/indexer.rs @@ -103,6 +103,7 @@ pub struct IndexedTable { /// The rom path, in the table folder or in the global pinmame roms folder rom_path: Option, /// deprecated: only used for reading the old index format + #[serde(skip_serializing_if = "Option::is_none")] local_rom_path: Option, pub wheel_path: Option, pub requires_pinmame: bool, diff --git a/vpxtool_shared/src/vpinball_config.rs b/vpxtool_shared/src/vpinball_config.rs index e9304ed..bc65c5c 100644 --- a/vpxtool_shared/src/vpinball_config.rs +++ b/vpxtool_shared/src/vpinball_config.rs @@ -1,5 +1,7 @@ use log::info; use std::fmt::Display; +use std::io; +use std::io::Read; use std::path::Path; #[derive(Debug, Clone, Copy)] @@ -80,13 +82,48 @@ pub struct VPinballConfig { ini: ini::Ini, } +impl Default for VPinballConfig { + fn default() -> Self { + Self::new() + } +} + impl VPinballConfig { - pub fn read(ini_path: &Path) -> Result { + pub fn new() -> Self { + VPinballConfig { + ini: ini::Ini::new(), + } + } + + pub fn read(ini_path: &Path) -> io::Result { info!("Reading vpinball ini file: {:?}", ini_path); - let ini = ini::Ini::load_from_file(ini_path)?; + let ini = ini::Ini::load_from_file(ini_path).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to read ini file: {:?}", e), + ) + })?; + Ok(VPinballConfig { ini }) + } + + pub fn read_from(reader: &mut R) -> io::Result { + let ini = ini::Ini::read_from(reader).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to read ini file: {:?}", e), + ) + })?; Ok(VPinballConfig { ini }) } + pub fn write(&self, ini_path: &Path) -> io::Result<()> { + self.ini.write_to_file(ini_path) + } + + pub fn write_to(&self, writer: &mut W) -> io::Result<()> { + self.ini.write_to(writer) + } + pub fn get_pinmame_path(&self) -> Option { if let Some(standalone_section) = self.ini.section(Some("Standalone")) { standalone_section.get("PinMAMEPath").map(|s| s.to_string()) @@ -140,35 +177,36 @@ impl VPinballConfig { } pub fn get_window_info(&self, window_type: WindowType) -> Option { + let section = section_name(window_type); match window_type { WindowType::Playfield => { - if let Some(standalone_section) = self.ini.section(Some("Player")) { + if let Some(ini_section) = self.ini.section(Some(section)) { // get all the values from PlayfieldXXX and fall back to the normal values - let fullscreen = match standalone_section.get("PlayfieldFullScreen") { + let fullscreen = match ini_section.get("PlayfieldFullScreen") { Some(value) => value == "1", - None => match standalone_section.get("FullScreen") { + None => match ini_section.get("FullScreen") { Some(value) => value == "1", None => true, // not sure if this is the correct default value for every os }, }; - let x = standalone_section + let x = ini_section .get("PlayfieldWndX") - .or_else(|| standalone_section.get("WindowPosX")) + .or_else(|| ini_section.get("WindowPosX")) .and_then(|s| s.parse::().ok()); - let y = standalone_section + let y = ini_section .get("PlayfieldWndY") - .or_else(|| standalone_section.get("WindowPosY")) + .or_else(|| ini_section.get("WindowPosY")) .and_then(|s| s.parse::().ok()); - let width = standalone_section + let width = ini_section .get("PlayfieldWidth") - .or_else(|| standalone_section.get("Width")) + .or_else(|| ini_section.get("Width")) .and_then(|s| s.parse::().ok()); - let height = standalone_section + let height = ini_section .get("PlayfieldHeight") - .or_else(|| standalone_section.get("Height")) + .or_else(|| ini_section.get("Height")) .and_then(|s| s.parse::().ok()); Some(WindowInfo { @@ -186,6 +224,43 @@ impl VPinballConfig { } } + pub fn set_window_position(&mut self, window_type: WindowType, x: u32, y: u32) { + let section = section_name(window_type); + let prefix = config_prefix(window_type); + // preferably we would write a comment but the ini crate does not support that + // see https://github.com/zonyitoo/rust-ini/issues/77 + + let x_suffix = match window_type { + WindowType::Playfield => "WndX", + _ => "X", + }; + let y_suffix = match window_type { + WindowType::Playfield => "WndY", + _ => "Y", + }; + + let x_key = format!("{}{}", prefix, x_suffix); + let y_key = format!("{}{}", prefix, y_suffix); + + self.ini + .with_section(Some(§ion)) + .set(x_key, x.to_string()) + .set(y_key, y.to_string()); + } + + pub fn set_window_size(&mut self, window_type: WindowType, width: u32, height: u32) { + let section = section_name(window_type); + let prefix = config_prefix(window_type); + + let width_key = format!("{}{}", prefix, "Width"); + let height_key = format!("{}{}", prefix, "Height"); + + self.ini + .with_section(Some(§ion)) + .set(width_key, width.to_string()) + .set(height_key, height.to_string()); + } + fn lookup_window_info(&self, window_type: WindowType) -> Option { let section = section_name(window_type); if let Some(ini_section) = self.ini.section(Some(section)) { @@ -221,3 +296,83 @@ impl VPinballConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use testdir::testdir; + + #[test] + fn test_read_vpinball_config() { + let testdir = testdir!(); + // manually create test ini file + let ini_path = testdir.join("test.ini"); + std::fs::write( + &ini_path, + r#" +[Player] +FullScreen=1 +PlayfieldFullScreen=1 +PlayfieldWndX=0 +PlayfieldWndY=0 +PlayfieldWidth=1920 +PlayfieldHeight=1080 +"#, + ) + .unwrap(); + + let config = VPinballConfig::read(&ini_path).unwrap(); + assert_eq!( + config + .get_window_info(WindowType::Playfield) + .unwrap() + .fullscreen, + true + ); + assert_eq!( + config.get_window_info(WindowType::Playfield).unwrap().x, + Some(0) + ); + assert_eq!( + config.get_window_info(WindowType::Playfield).unwrap().y, + Some(0) + ); + assert_eq!( + config.get_window_info(WindowType::Playfield).unwrap().width, + Some(1920) + ); + assert_eq!( + config + .get_window_info(WindowType::Playfield) + .unwrap() + .height, + Some(1080) + ); + } + + #[test] + fn test_write_vpinball_config() { + let mut config = VPinballConfig::default(); + config.set_window_position(WindowType::Playfield, 100, 200); + config.set_window_size(WindowType::Playfield, 300, 400); + let mut cursor = io::Cursor::new(Vec::new()); + config.write_to(&mut cursor).unwrap(); + cursor.set_position(0); + let config_read = VPinballConfig::read_from(&mut cursor).unwrap(); + assert_eq!( + config_read + .get_window_info(WindowType::Playfield) + .unwrap() + .x, + Some(100) + ); + assert_eq!( + config_read + .get_window_info(WindowType::Playfield) + .unwrap() + .y, + Some(200) + ); + } +}