From b471d6e22f035ca3e7f382ed793fd6319332582a Mon Sep 17 00:00:00 2001 From: "Wyatt J. Miller" Date: Wed, 26 Aug 2026 21:33:44 -0400 Subject: [PATCH] add: refactored everything --- .envrc | 1 + Cargo.lock | 100 +------ Cargo.toml | 5 - README.md | 20 +- build.rs | 18 +- flake.lock | 27 ++ flake.nix | 79 ++++++ resources/appmenu.xml | 12 +- resources/style.css | 31 +-- src/boxes.rs | 196 -------------- src/main.rs | 606 ++++++++++++++++++++++++++++++++++++------ src/menu.rs | 4 - src/patch.rs | 369 +++++++++++++++++++++++-- src/popover.rs | 73 ----- src/rom.rs | 247 +++++++++++++++++ src/titlebar.rs | 88 ------ 16 files changed, 1268 insertions(+), 608 deletions(-) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 src/boxes.rs delete mode 100644 src/menu.rs delete mode 100644 src/popover.rs create mode 100644 src/rom.rs delete mode 100644 src/titlebar.rs diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/Cargo.lock b/Cargo.lock index 4c47fe9..b6e6d5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "anyhow" @@ -20,12 +20,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - [[package]] name = "cairo-rs" version = "0.15.11" @@ -69,21 +63,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "futures" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.15" @@ -91,7 +70,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -117,25 +95,6 @@ version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" -[[package]] -name = "futures-macro" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" -dependencies = [ - "autocfg", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a57bead0ceff0d6dde8f465ecd96c9338121bb7717d3e7b108059531870c4282" - [[package]] name = "futures-task" version = "0.3.15" @@ -149,36 +108,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" dependencies = [ "autocfg", - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", - "proc-macro-hack", - "proc-macro-nested", "slab", ] -[[package]] -name = "gdk" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8" -dependencies = [ - "bitflags", - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - [[package]] name = "gdk-pixbuf" version = "0.15.11" @@ -205,23 +141,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "gdk-sys" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - [[package]] name = "gdk4" version = "0.4.7" @@ -572,18 +491,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" - -[[package]] -name = "proc-macro-nested" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" - [[package]] name = "proc-macro2" version = "1.0.27" @@ -660,11 +567,6 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" name = "snespatcher" version = "0.1.0" dependencies = [ - "byteorder", - "futures", - "gdk", - "gio", - "glib", "gtk4", ] diff --git a/Cargo.toml b/Cargo.toml index 16d1daf..939c6fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,3 @@ edition = "2018" [dependencies] gtk4 = "0.4.7" -glib = "*" -gio = "*" -gdk = "*" -futures = "0.3" -byteorder = "*" \ No newline at end of file diff --git a/README.md b/README.md index 56bdfb8..1b9b580 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,24 @@ # SNES Patcher -Patch your SNES games with IPS or BPS patches, starring SNES Patcher! +Create patched copies of Super Nintendo ROMs with IPS or BPS patches. ## Features -- Written with GTK4, not GTK3 -- Written in Rust, not C/C++ -- Simple, intuitive user interface +- Native GTK 4 interface designed for GNOME +- Safe workflow that preserves the original ROM +- IPS records, RLE records, and optional truncation support +- BPS actions, metadata, and checksum validation ## What is this? -SNES Patcher is a simple GTK4 application where you can patch your SNES ROMs with IPS or BPS patches. +SNES Patcher is a small GTK 4 application for applying IPS and BPS patches to SNES ROMs. Choose a patch format, select a ROM and patch, then choose where to save the result. The original ROM remains untouched. ## Why? The idea to build this application came from the [Super Mario World widescreen patch](https://github.com/VitorVilela7/wide-snes) built by [Victor Vilela](https://github.com/VitorVilela7/). Being a big fan of Super Mario World, I was eager to try it out. I happened to find FLIPS, another IPS/BPS patching utility. Functionally, FLIPS worked but aesthetically, FLIPS looked horrible. I decided to scratch my own itch and built the same utility, but from scratch. Thus, SNES Patcher was born. ## Where can I download it? -Currently, you can build from source by downloading `rustup`, thusly downloading `rustc` and `cargo`. Packages coming soon! \ No newline at end of file +Currently, you can build from source by downloading `rustup`, thusly downloading `rustc` and `cargo`. Packages coming soon! + +## Building +Install the GTK 4 development files and GLib resource compiler, then run: + +```sh +cargo run +``` diff --git a/build.rs b/build.rs index 50485ac..3ddfdce 100644 --- a/build.rs +++ b/build.rs @@ -1,13 +1,15 @@ use std::process::Command; fn main() { - // let _ = Command::new("sh") - // .args(&["-c", "cd resources && gtk4-builder-tool simplify --3to4 snespatcher.ui --replace"]) - // .output() - // .expect("failed to execute process"); + println!("cargo:rerun-if-changed=resources/resources.xml"); + println!("cargo:rerun-if-changed=resources/style.css"); + println!("cargo:rerun-if-changed=resources/appmenu.xml"); - let _ = Command::new("sh") - .args(&["-c", "cd resources && glib-compile-resources resources.xml"]) - .output() - .expect("failed to execute process"); + let status = Command::new("glib-compile-resources") + .arg("resources.xml") + .current_dir("resources") + .status() + .expect("failed to run glib-compile-resources"); + + assert!(status.success(), "failed to compile application resources"); } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..16c1ef9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1787498568, + "narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4baf4b5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,79 @@ +{ + description = "SNES Patcher"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + rec { + snespatcher = pkgs.rustPlatform.buildRustPackage { + pname = "snespatcher"; + version = "0.1.0"; + + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + name = baseNameOf path; + in + !builtins.elem name [ + ".agents" + ".codex" + ".git" + "target" + ]; + }; + + cargoLock.lockFile = ./Cargo.lock; + + nativeBuildInputs = with pkgs; [ + glib + pkg-config + wrapGAppsHook4 + ]; + + buildInputs = with pkgs; [ + gtk4 + ]; + + meta = { + description = "GTK 4 application for applying IPS patches to SNES ROMs"; + homepage = "https://scm.wyattjmiller.com/wymiller/snespatcher"; + license = pkgs.lib.licenses.mpl20; + mainProgram = "snespatcher"; + platforms = pkgs.lib.platforms.linux; + }; + }; + + default = snespatcher; + }); + + devShells = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + inputsFrom = [ self.packages.${system}.default ]; + + packages = with pkgs; [ + cargo + clippy + rustc + rustfmt + ]; + }; + }); + }; +} diff --git a/resources/appmenu.xml b/resources/appmenu.xml index a7c08cb..683d574 100644 --- a/resources/appmenu.xml +++ b/resources/appmenu.xml @@ -1,15 +1,17 @@ - +
- _About SNES Patcher - app.about + _About SNES Patcher + app.about +
+
- _Quit - app.quit + _Quit + app.quit
diff --git a/resources/style.css b/resources/style.css index 76b81bc..370c1f3 100644 --- a/resources/style.css +++ b/resources/style.css @@ -1,26 +1,23 @@ -.run_btn:focus { - background: #1c71d8; - color: #ffffff; +.main-content { + min-width: 356px; } -.run_btn:hover { - background: #3584e4; +.title { + font-size: 1.5rem; + font-weight: 700; } -.run_btn:active { - background: #1a5fb4; +.heading { + font-weight: 600; } -.run_btn { - background: #1a5fb4; +.selection-row { + min-height: 48px; + padding: 10px 12px; + border-radius: 0; } -.run_btn:disabled { - background: #3d3846; - color: #5e5c64; +.format-row { + min-height: 48px; + padding: 10px 12px; } - -.headerbar { - background: #353535; - border: 0; -} \ No newline at end of file diff --git a/src/boxes.rs b/src/boxes.rs deleted file mode 100644 index 62d183c..0000000 --- a/src/boxes.rs +++ /dev/null @@ -1,196 +0,0 @@ -use std::rc::Rc; - -use gtk4::{prelude::*, Window, FileFilter}; -use gtk4::{ - glib::*, - ApplicationWindow, - Box, - Button, - FileChooserAction, - FileChooserDialog, - Label, - Orientation, - ResponseType, -}; - -pub trait CustomBox { - fn click_button(&self) {} - fn set_button(&self) {} - fn set_label(&self) {} - fn set_box(&self) {} -} - -pub struct AppBox { - pub app_box: Box, -} - -#[derive(Debug, Downgrade)] -pub struct RomBox { - pub rom_box: Box, - pub rom_button: Button, - pub rom_label: Label, -} - -#[derive(Debug, Downgrade)] -pub struct PatchBox { - pub patch_box: Box, - pub patch_button: Button, - pub patch_label: Label, -} - -impl AppBox { - pub fn new() -> AppBox { - AppBox { - app_box: Box::new(Orientation::Vertical, 0), - } - } - - pub fn set_box(&self, rom_box: &RomBox, patch_box: &PatchBox) { - self.app_box.set_visible(true); - self.app_box.append(&rom_box.rom_box); - self.app_box.append(&patch_box.patch_box); - } -} - -impl RomBox { - pub fn new(app: &ApplicationWindow) -> RomBox { - RomBox { - rom_box: Box::new(Orientation::Horizontal, 0), - rom_button: Button::new(), - rom_label: Label::new(Some("Choose ROM")), - } - } - - pub fn click_button(&self, window: Window) { - self.rom_button.connect_clicked(clone!(@strong self as this => - move |_| { - let dialog = FileChooserDialog::new( - Some("Choose a ROM"), - Some(&window), - FileChooserAction::Open, - &[("_Cancel", ResponseType::Cancel), ("_Open", ResponseType::Accept)] - ); - - // here's to set the dialog to be modal - dialog.set_modal(true); - - // add a filter! - let rom_filter = FileFilter::new(); - rom_filter.add_mime_type("application/vnd.nintendo.snes.rom"); - rom_filter.set_name(Some("SNES ROMs")); - dialog.add_filter(&rom_filter); - - // run the dialog - dialog.run_async(|d, r| { - match r { - ResponseType::Accept => d.close(), - ResponseType::Cancel => d.close(), - _ => d.close(), - } - }) - - - - // if this.buttons[1] == this.response(ResponseType::Ok) { - - // } - })); - - // self.rom_filechooser.connect_response(clone!(@strong self.rom_filechooser as this => - // move |_, ResponseType| { - - // })); - } -} - -impl CustomBox for RomBox { - fn set_button(&self) { - self.rom_button.set_margin_top(20); - self.rom_button.set_margin_bottom(0); - self.rom_button.set_margin_start(20); - self.rom_button.set_margin_end(20); - self.rom_button.set_visible(true); - self.rom_button.set_label("Open"); - } - - fn set_label(&self) { - self.rom_label.set_margin_top(20); - self.rom_label.set_margin_bottom(0); - self.rom_label.set_margin_start(20); - self.rom_label.set_margin_end(20); - self.rom_label.set_visible(true); - } - - fn set_box(&self) { - self.rom_box.set_homogeneous(true); - self.rom_box.set_visible(true); - self.rom_box.append(&self.rom_label); - self.rom_box.append(&self.rom_button); - } -} - -impl PatchBox { - pub fn new(app: &ApplicationWindow) -> PatchBox { - PatchBox { - patch_box: Box::new(Orientation::Horizontal, 0), - patch_button: Button::new(), - patch_label: Label::new(Some("Choose Patch")), - } - } - - pub fn click_button(&self, window: Window) { - self.patch_button.connect_clicked(clone!(@weak self as this => - move |_| { - let dialog = FileChooserDialog::new( - Some("Choose a Patch"), - Some(&window), - FileChooserAction::Open, - &[("_Cancel", ResponseType::Cancel), ("_Open", ResponseType::Accept)] - ); - - // here's to set the dialog to be modal - dialog.set_modal(true); - - let patch_filter = FileFilter::new(); - patch_filter.add_mime_type("application/x-bps-patch"); - patch_filter.add_mime_type("application/x-ips-patch"); - patch_filter.set_name(Some("Patches")); - dialog.add_filter(&patch_filter); - - // run the dialog - dialog.run_async(|d, r| { - match r { - ResponseType::Accept => d.close(), - ResponseType::Cancel => d.close(), - _ => d.close(), - } - }) - })); - } -} - -impl CustomBox for PatchBox{ - fn set_button(&self) { - self.patch_button.set_margin_top(20); - self.patch_button.set_margin_bottom(20); - self.patch_button.set_margin_start(20); - self.patch_button.set_margin_end(20); - self.patch_button.set_visible(true); - self.patch_button.set_label("Open"); - } - - fn set_label(&self) { - self.patch_label.set_margin_top(20); - self.patch_label.set_margin_bottom(20); - self.patch_label.set_margin_start(20); - self.patch_label.set_margin_end(20); - self.patch_label.set_visible(true); - } - - fn set_box(&self) { - self.patch_box.set_homogeneous(true); - self.patch_box.set_visible(true); - self.patch_box.append(&self.patch_label); - self.patch_box.append(&self.patch_button); - } -} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 25fca49..b569b3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,113 +1,543 @@ mod patch; -mod titlebar; -mod boxes; -mod menu; -mod popover; +mod rom; -use titlebar::CustomTitleBar; -use boxes::*; -use popover::CustomPopoverMenu; - -use std::path::PathBuf; +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; use std::rc::Rc; -use gtk4::{prelude::*, Window}; +use gtk4::gio; +use gtk4::glib; +use gtk4::pango::EllipsizeMode; +use gtk4::prelude::*; use gtk4::{ - gdk::{ - Display - }, - gio::MenuModel, - Application, - ApplicationWindow, - Builder, - StyleContext, + AboutDialog, Align, Application, ApplicationWindow, Box as GtkBox, Button, ButtonsType, + DropDown, FileChooserAction, FileChooserNative, FileFilter, Frame, HeaderBar, Image, + Justification, Label, License, MenuButton, MessageDialog, Orientation, ResponseType, Separator, }; +const APP_ID: &str = "com.wyattjmiller.snespatcher"; + +#[derive(Clone)] +struct SelectionState { + rom: Rc>>, + patch: Rc>>, + format: Rc>, +} + +struct SelectionRow { + button: Button, + title: Label, + value: Label, +} + fn main() { - let app = Application::new(Some("com.wyattjmiller.snespatcher"), Default::default()); + let app = Application::new(Some(APP_ID), Default::default()); app.connect_activate(build_ui); app.run(); } fn build_ui(app: &Application) { - let resources_bytes = include_bytes!("../resources/resources.gresource"); - let resource_data = glib::Bytes::from(&resources_bytes[..]); - let res = gio::Resource::from_data(&resource_data).unwrap(); - gio::resources_register(&res); + register_resources(); + + let window = ApplicationWindow::builder() + .application(app) + .title("SNES Patcher") + .default_width(560) + .default_height(440) + .build(); + window.set_size_request(420, 360); + + let apply_button = Button::with_mnemonic("_Apply Patch"); + apply_button.add_css_class("suggested-action"); + apply_button.set_sensitive(false); + apply_button.set_tooltip_text(Some("Create a patched copy of the selected ROM")); + + let rom_info_button = Button::new(); + rom_info_button.set_icon_name("dialog-information-symbolic"); + rom_info_button.set_sensitive(false); + rom_info_button.set_tooltip_text(Some("Show information about the selected ROM")); + + let header_bar = build_header_bar(app, &window, &apply_button, &rom_info_button); + window.set_titlebar(Some(&header_bar)); + + let content = GtkBox::new(Orientation::Vertical, 24); + content.add_css_class("main-content"); + content.set_margin_top(32); + content.set_margin_bottom(24); + content.set_margin_start(32); + content.set_margin_end(32); + + let introduction = GtkBox::new(Orientation::Vertical, 8); + let heading = Label::new(Some("Patch a Super Nintendo ROM")); + heading.add_css_class("title"); + heading.set_justify(Justification::Center); + heading.set_wrap(true); + + let description = Label::new(Some( + "Choose an unmodified ROM and a patch. Your original file will not be changed.", + )); + description.add_css_class("dim-label"); + description.set_justify(Justification::Center); + description.set_wrap(true); + description.set_max_width_chars(52); + introduction.append(&heading); + introduction.append(&description); + + let rom_row = selection_row( + "Game ROM", + "Select an SFC or SMC file", + "media-floppy-symbolic", + ); + let (format_row, format_selector) = format_row(); + let patch_row = selection_row("IPS Patch", "Select an IPS file", "document-open-symbolic"); + + let selection_list = GtkBox::new(Orientation::Vertical, 0); + selection_list.append(&format_row); + selection_list.append(&Separator::new(Orientation::Horizontal)); + selection_list.append(&rom_row.button); + selection_list.append(&Separator::new(Orientation::Horizontal)); + selection_list.append(&patch_row.button); + + let selection_frame = Frame::new(None); + selection_frame.set_child(Some(&selection_list)); + + let status_label = Label::new(Some("Select a ROM and an IPS patch to continue.")); + status_label.add_css_class("dim-label"); + status_label.set_halign(Align::Start); + status_label.set_wrap(true); + status_label.set_xalign(0.0); + + content.append(&introduction); + content.append(&selection_frame); + content.append(&status_label); + window.set_child(Some(&content)); + + let state = SelectionState { + rom: Rc::new(RefCell::new(None)), + patch: Rc::new(RefCell::new(None)), + format: Rc::new(Cell::new(patch::PatchFormat::Ips)), + }; + + connect_format_selector( + &format_selector, + state.clone(), + &patch_row.title, + &patch_row.value, + &apply_button, + &status_label, + ); + + connect_file_picker( + &rom_row.button, + &window, + "Choose a Game ROM", + rom_filter(), + Rc::clone(&state.rom), + &rom_row.value, + state.clone(), + &apply_button, + &rom_info_button, + &status_label, + ); + connect_patch_picker( + &patch_row.button, + &window, + &patch_row.value, + state.clone(), + &apply_button, + &status_label, + ); + connect_rom_info(&rom_info_button, &window, state.clone()); + connect_apply(&apply_button, &window, state, &status_label); + + window.present(); +} + +fn register_resources() { + let bytes = include_bytes!("../resources/resources.gresource"); + let data = glib::Bytes::from(&bytes[..]); + let resource = gio::Resource::from_data(&data).expect("Could not load application resources"); + gio::resources_register(&resource); let provider = gtk4::CssProvider::new(); provider.load_from_resource("/com/wyattjmiller/snespatcher/style.css"); - StyleContext::add_provider_for_display( - &Display::default().expect("Error initializing CSS provider!"), + gtk4::StyleContext::add_provider_for_display( + >k4::gdk::Display::default().expect("Could not connect to a display"), &provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); - - let builder = Builder::new(); - builder.add_from_resource("/com/wyattjmiller/snespatcher/appmenu.xml").expect("Error initializing app menu!"); - //println!("{:?}", builder.list_properties()); - - let app_window: Rc = Rc::new(ApplicationWindow::new(app)); - let file_picker_window: Window = Window::new(); - let popover_menu: CustomPopoverMenu = CustomPopoverMenu::new(); - let header_bar: CustomTitleBar = CustomTitleBar::new(&popover_menu); - let app_box: AppBox = AppBox::new(); - let patch_box: PatchBox = PatchBox::new(&app_window); - let rom_box: RomBox = RomBox::new(&app_window); - - // set popover_menu methods - popover_menu.set_texts(); - popover_menu.click_about_button(); - - // TODO: Set popover textviews and put them in a separate file - // set header bar methods - header_bar.set_buttons(); - header_bar.set_titlebar(); - header_bar.set_window(&app_window); - header_bar.click_run_button(); - header_bar.click_menu(); - - // set patch box methods - patch_box.set_label(); - patch_box.set_button(); - patch_box.set_box(); - patch_box.click_button(file_picker_window.clone()); - - // set rom box methods - rom_box.set_label(); - rom_box.set_button(); - rom_box.set_box(); - rom_box.click_button(file_picker_window.clone()); - - // set app box methods - app_box.set_box(&rom_box, &patch_box); - - // set apps default methods - app_window.set_title(Some("SNES Patcher")); - app_window.set_resizable(false); - app_window.set_child(Some(&app_box.app_box)); - app_window.show(); } -async fn dialog>(window: Rc) { - let question_dialog = gtk4::MessageDialog::builder() - .transient_for(&*window) - .modal(true) - .buttons(gtk4::ButtonsType::OkCancel) - .text("What is your answer?") - .build(); +fn build_header_bar( + app: &Application, + window: &ApplicationWindow, + apply_button: &Button, + rom_info_button: &Button, +) -> HeaderBar { + let builder = gtk4::Builder::from_resource("/com/wyattjmiller/snespatcher/appmenu.xml"); + let menu_model = builder + .object::("app-menu") + .expect("Application menu is missing from resources"); - let answer = question_dialog.run_future().await; - question_dialog.close(); + let menu_button = MenuButton::new(); + menu_button.set_icon_name("open-menu-symbolic"); + menu_button.set_menu_model(Some(&menu_model)); + menu_button.set_tooltip_text(Some("Main Menu")); - let info_dialog = gtk4::MessageDialog::builder() - .transient_for(&*window) - .modal(true) - .buttons(gtk4::ButtonsType::Close) - .text("You answered") - .secondary_text(&format!("Your answer: {:?}", answer)) - .build(); + let header_bar = HeaderBar::new(); + header_bar.set_show_title_buttons(true); + header_bar.pack_start(apply_button); + header_bar.pack_start(rom_info_button); + header_bar.pack_end(&menu_button); - info_dialog.run_future().await; - info_dialog.close(); + let about = gio::SimpleAction::new("about", None); + about.connect_activate(glib::clone!(@weak window => move |_, _| { + let dialog = AboutDialog::builder() + .transient_for(&window) + .modal(true) + .program_name("SNES Patcher") + .version(env!("CARGO_PKG_VERSION")) + .comments("Create patched copies of Super Nintendo ROMs using IPS or BPS patches. This project is dedicated to my younger brother.") + .authors(vec!["Wyatt J. Miller".to_string()]) + .license_type(License::Mpl20) + .website("https://scm.wyattjmiller.com/wymiller/snespatcher") + .website_label("Project Website") + .build(); + dialog.present(); + })); + app.add_action(&about); + + let quit = gio::SimpleAction::new("quit", None); + quit.connect_activate(glib::clone!(@weak app => move |_, _| app.quit())); + app.add_action(&quit); + app.set_accels_for_action("app.quit", &["q"]); + + header_bar +} + +fn selection_row(title: &str, placeholder: &str, icon_name: &str) -> SelectionRow { + let icon = Image::from_icon_name(icon_name); + icon.set_pixel_size(24); + + let labels = GtkBox::new(Orientation::Vertical, 2); + labels.set_hexpand(true); + + let title_label = Label::new(Some(title)); + title_label.add_css_class("heading"); + title_label.set_halign(Align::Start); + title_label.set_xalign(0.0); + + let value = Label::new(Some(placeholder)); + value.add_css_class("dim-label"); + value.set_ellipsize(EllipsizeMode::Middle); + value.set_halign(Align::Start); + value.set_xalign(0.0); + labels.append(&title_label); + labels.append(&value); + + let arrow = Image::from_icon_name("go-next-symbolic"); + arrow.add_css_class("dim-label"); + + let row_content = GtkBox::new(Orientation::Horizontal, 12); + row_content.append(&icon); + row_content.append(&labels); + row_content.append(&arrow); + + let button = Button::new(); + button.add_css_class("flat"); + button.add_css_class("selection-row"); + button.set_child(Some(&row_content)); + + SelectionRow { + button, + title: title_label, + value, + } +} + +fn format_row() -> (GtkBox, DropDown) { + let icon = Image::from_icon_name("document-properties-symbolic"); + icon.set_pixel_size(24); + + let labels = GtkBox::new(Orientation::Vertical, 2); + labels.set_hexpand(true); + let title = Label::new(Some("Patch Format")); + title.add_css_class("heading"); + title.set_halign(Align::Start); + title.set_xalign(0.0); + let description = Label::new(Some("Choose how the patch is encoded")); + description.add_css_class("dim-label"); + description.set_halign(Align::Start); + description.set_xalign(0.0); + labels.append(&title); + labels.append(&description); + + let selector = DropDown::from_strings(&["IPS", "BPS"]); + selector.set_tooltip_text(Some("Patch format")); + + let row = GtkBox::new(Orientation::Horizontal, 12); + row.add_css_class("format-row"); + row.append(&icon); + row.append(&labels); + row.append(&selector); + (row, selector) +} + +fn connect_format_selector( + selector: &DropDown, + state: SelectionState, + patch_title: &Label, + patch_value: &Label, + apply_button: &Button, + status_label: &Label, +) { + selector.connect_selected_notify(glib::clone!(@weak patch_title, @weak patch_value, @weak apply_button, @weak status_label => move |selector| { + let format = if selector.selected() == 1 { + patch::PatchFormat::Bps + } else { + patch::PatchFormat::Ips + }; + if state.format.get() == format { + return; + } + + state.format.set(format); + *state.patch.borrow_mut() = None; + patch_title.set_text(&format!("{} Patch", patch_format_name(format))); + patch_value.set_text(&format!( + "Select {} file", + patch_format_with_article(format) + )); + patch_value.add_css_class("dim-label"); + update_ready_state(&state, &apply_button, &status_label); + })); +} + +#[allow(clippy::too_many_arguments)] +fn connect_file_picker( + button: &Button, + window: &ApplicationWindow, + title: &'static str, + filter: FileFilter, + selected: Rc>>, + value_label: &Label, + state: SelectionState, + apply_button: &Button, + rom_info_button: &Button, + status_label: &Label, +) { + button.connect_clicked(glib::clone!(@weak window, @weak value_label, @weak apply_button, @weak rom_info_button, @weak status_label => move |_| { + let chooser = FileChooserNative::new( + Some(title), + Some(&window), + FileChooserAction::Open, + Some("_Open"), + Some("_Cancel"), + ); + chooser.add_filter(&filter); + chooser.connect_response(glib::clone!(@strong selected, @strong state, @weak value_label, @weak apply_button, @weak rom_info_button, @weak status_label => move |dialog, response| { + if response == ResponseType::Accept { + if let Some(path) = dialog.file().and_then(|file| file.path()) { + value_label.set_text(&display_name(&path)); + value_label.remove_css_class("dim-label"); + *selected.borrow_mut() = Some(path); + rom_info_button.set_sensitive(true); + update_ready_state(&state, &apply_button, &status_label); + } + } + dialog.hide(); + })); + chooser.show(); + })); +} + +fn connect_rom_info(button: &Button, window: &ApplicationWindow, state: SelectionState) { + button.connect_clicked(glib::clone!(@weak window => move |_| { + let (title, details) = match state.rom.borrow().as_deref() { + Some(path) => match rom::RomInfo::read(path) { + Ok(info) => ("Selected ROM", info.summary()), + Err(error) => ( + "Could Not Read ROM", + format!("The selected ROM is no longer available: {error}"), + ), + }, + None => ( + "No ROM Selected", + "Choose a game ROM before viewing its information.".to_string(), + ), + }; + + let dialog = MessageDialog::builder() + .transient_for(&window) + .modal(true) + .buttons(ButtonsType::Close) + .text(title) + .secondary_text(&details) + .build(); + dialog.connect_response(|dialog, _| dialog.close()); + dialog.present(); + })); +} + +fn connect_patch_picker( + button: &Button, + window: &ApplicationWindow, + value_label: &Label, + state: SelectionState, + apply_button: &Button, + status_label: &Label, +) { + button.connect_clicked(glib::clone!(@weak window, @weak value_label, @weak apply_button, @weak status_label => move |_| { + let format = state.format.get(); + let title = format!("Choose {} Patch", patch_format_with_article(format)); + let chooser = FileChooserNative::new( + Some(&title), + Some(&window), + FileChooserAction::Open, + Some("_Open"), + Some("_Cancel"), + ); + chooser.add_filter(&patch_filter(format)); + chooser.connect_response(glib::clone!(@strong state, @weak value_label, @weak apply_button, @weak status_label => move |dialog, response| { + if response == ResponseType::Accept { + if let Some(path) = dialog.file().and_then(|file| file.path()) { + value_label.set_text(&display_name(&path)); + value_label.remove_css_class("dim-label"); + *state.patch.borrow_mut() = Some(path); + update_ready_state(&state, &apply_button, &status_label); + } + } + dialog.hide(); + })); + chooser.show(); + })); +} + +fn connect_apply( + button: &Button, + window: &ApplicationWindow, + state: SelectionState, + status_label: &Label, +) { + button.connect_clicked(glib::clone!(@weak window, @weak status_label => move |_| { + let (rom, patch_path) = match (state.rom.borrow().clone(), state.patch.borrow().clone()) { + (Some(rom), Some(patch_path)) => (rom, patch_path), + _ => return, + }; + let format = state.format.get(); + + let chooser = FileChooserNative::new( + Some("Save Patched ROM"), + Some(&window), + FileChooserAction::Save, + Some("_Save"), + Some("_Cancel"), + ); + chooser.set_current_name(&patched_file_name(&rom)); + chooser.add_filter(&rom_filter()); + chooser.connect_response(glib::clone!(@weak status_label => move |dialog, response| { + if response == ResponseType::Accept { + if let Some(destination) = dialog.file().and_then(|file| file.path()) { + match patch::apply(format, &patch_path, &rom, &destination) { + Ok(()) => set_status( + &status_label, + &format!("Patched ROM saved as {}.", display_name(&destination)), + Some("success"), + ), + Err(error) => set_status( + &status_label, + &format!("Could not apply the patch: {error}"), + Some("error"), + ), + } + } + } + dialog.hide(); + })); + chooser.show(); + })); +} + +fn update_ready_state(state: &SelectionState, button: &Button, status: &Label) { + let has_rom = state.rom.borrow().is_some(); + let has_patch = state.patch.borrow().is_some(); + button.set_sensitive(has_rom && has_patch); + + let message = match (has_rom, has_patch) { + (true, true) => "Ready to create a patched copy.".to_string(), + (true, false) => format!( + "Select {} patch to continue.", + patch_format_with_article(state.format.get()) + ), + (false, true) => "Select a game ROM to continue.".to_string(), + (false, false) => format!( + "Select a ROM and {} patch to continue.", + patch_format_with_article(state.format.get()) + ), + }; + set_status(status, &message, None); +} + +fn set_status(label: &Label, message: &str, style: Option<&str>) { + label.set_text(message); + label.remove_css_class("dim-label"); + label.remove_css_class("success"); + label.remove_css_class("error"); + if let Some(style) = style { + label.add_css_class(style); + } else { + label.add_css_class("dim-label"); + } +} + +fn rom_filter() -> FileFilter { + let filter = FileFilter::new(); + filter.set_name(Some("Super Nintendo ROMs")); + filter.add_pattern("*.sfc"); + filter.add_pattern("*.SFC"); + filter.add_pattern("*.smc"); + filter.add_pattern("*.SMC"); + filter +} + +fn patch_filter(format: patch::PatchFormat) -> FileFilter { + let filter = FileFilter::new(); + let name = patch_format_name(format); + filter.set_name(Some(&format!("{name} patches"))); + filter.add_pattern(&format!("*.{}", name.to_ascii_lowercase())); + filter.add_pattern(&format!("*.{name}")); + filter +} + +fn patch_format_name(format: patch::PatchFormat) -> &'static str { + match format { + patch::PatchFormat::Ips => "IPS", + patch::PatchFormat::Bps => "BPS", + } +} + +fn patch_format_with_article(format: patch::PatchFormat) -> &'static str { + match format { + patch::PatchFormat::Ips => "an IPS", + patch::PatchFormat::Bps => "a BPS", + } +} + +fn display_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Selected file") + .to_string() +} + +fn patched_file_name(path: &Path) -> String { + let stem = path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("game"); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("sfc"); + format!("{stem} (Patched).{extension}") } diff --git a/src/menu.rs b/src/menu.rs deleted file mode 100644 index 0d6ff99..0000000 --- a/src/menu.rs +++ /dev/null @@ -1,4 +0,0 @@ -enum PatchType { - Ips, - Bps, -} \ No newline at end of file diff --git a/src/patch.rs b/src/patch.rs index faeac98..108c10b 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -1,23 +1,354 @@ -use std::fs::File; -use std::io::{Read, Seek, SeekFrom}; -use std::path::{Path, PathBuf}; +use std::convert::{TryFrom, TryInto}; +use std::fs; +use std::path::Path; -use byteorder::*; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PatchFormat { + Ips, + Bps, +} -pub fn apply_ips(ips_file: PathBuf, binary: PathBuf) { - match ips_file.exists() || binary.exists() { - true => { - let mut ips = File::open(ips_file).unwrap(); - let mut bin = File::open(binary).unwrap(); +pub fn apply( + format: PatchFormat, + patch_path: &Path, + rom_path: &Path, + destination: &Path, +) -> Result<(), String> { + let patch = + fs::read(patch_path).map_err(|error| format!("could not read the patch ({error})"))?; + let rom = fs::read(rom_path).map_err(|error| format!("could not read the ROM ({error})"))?; + let patched = match format { + PatchFormat::Ips => apply_ips_bytes(&patch, rom)?, + PatchFormat::Bps => apply_bps_bytes(&patch, rom)?, + }; + fs::write(destination, patched).map_err(|error| format!("could not save the ROM ({error})")) +} - let mut ips_str = String::new(); - let mut bin_str = String::new(); - ips.read_to_string(&mut ips_str).unwrap(); - bin.read_to_string(&mut bin_str).unwrap(); - - ips.seek(SeekFrom::Start(5)).unwrap(); - println!("{:?}", ips.read_f32::().unwrap()); - }, - false => panic!("\u{1F926}"), +fn apply_ips_bytes(patch: &[u8], mut rom: Vec) -> Result, String> { + if !patch.starts_with(b"PATCH") { + return Err("the selected file is not a valid IPS patch".to_string()); } -} \ No newline at end of file + + let mut cursor = 5; + loop { + if patch.get(cursor..cursor + 3) == Some(b"EOF") { + cursor += 3; + if patch.len() == cursor + 3 { + rom.resize(read_u24(patch, &mut cursor)?, 0); + } else if patch.len() != cursor { + return Err("the IPS patch has unexpected data after its end marker".to_string()); + } + return Ok(rom); + } + + let offset = read_u24(patch, &mut cursor)?; + let size = read_u16(patch, &mut cursor)?; + if size == 0 { + let run_length = read_u16(patch, &mut cursor)?; + let value = *patch + .get(cursor) + .ok_or_else(|| "the IPS patch contains an incomplete RLE record".to_string())?; + cursor += 1; + write_record(&mut rom, offset, &vec![value; run_length])?; + } else { + let end = cursor + .checked_add(size) + .ok_or_else(|| "the IPS record is too large".to_string())?; + let data = patch + .get(cursor..end) + .ok_or_else(|| "the IPS patch contains an incomplete record".to_string())?; + write_record(&mut rom, offset, data)?; + cursor = end; + } + } +} + +fn write_record(rom: &mut Vec, offset: usize, data: &[u8]) -> Result<(), String> { + let end = offset + .checked_add(data.len()) + .ok_or_else(|| "the IPS record is too large".to_string())?; + if rom.len() < end { + rom.resize(end, 0); + } + rom[offset..end].copy_from_slice(data); + Ok(()) +} + +fn read_u16(data: &[u8], cursor: &mut usize) -> Result { + let bytes = data + .get(*cursor..*cursor + 2) + .ok_or_else(|| "the IPS patch ended unexpectedly".to_string())?; + *cursor += 2; + Ok((usize::from(bytes[0]) << 8) | usize::from(bytes[1])) +} + +fn read_u24(data: &[u8], cursor: &mut usize) -> Result { + let bytes = data + .get(*cursor..*cursor + 3) + .ok_or_else(|| "the IPS patch ended unexpectedly".to_string())?; + *cursor += 3; + Ok((usize::from(bytes[0]) << 16) | (usize::from(bytes[1]) << 8) | usize::from(bytes[2])) +} + +fn apply_bps_bytes(patch: &[u8], source: Vec) -> Result, String> { + if !patch.starts_with(b"BPS1") { + return Err("the selected file is not a valid BPS patch".to_string()); + } + + let footer_start = patch + .len() + .checked_sub(12) + .filter(|end| *end >= 4) + .ok_or_else(|| "the BPS patch is incomplete".to_string())?; + let mut cursor = 4; + let source_size = read_bps_number(patch, &mut cursor, footer_start)?; + let target_size = read_bps_number(patch, &mut cursor, footer_start)?; + let metadata_size = read_bps_number(patch, &mut cursor, footer_start)?; + + if source_size != source.len() { + return Err(format!( + "the BPS patch expects a {source_size}-byte ROM, but the selected ROM is {} bytes", + source.len() + )); + } + + cursor = cursor + .checked_add(metadata_size) + .filter(|end| *end <= footer_start) + .ok_or_else(|| "the BPS patch contains incomplete metadata".to_string())?; + + let mut target = Vec::new(); + target + .try_reserve_exact(target_size) + .map_err(|_| "the BPS target ROM is too large".to_string())?; + let mut source_relative_offset = 0_i128; + let mut target_relative_offset = 0_i128; + + while target.len() < target_size { + let action = read_bps_number(patch, &mut cursor, footer_start)?; + let mode = action & 3; + let length = (action >> 2) + .checked_add(1) + .ok_or_else(|| "the BPS action is too large".to_string())?; + if length > target_size - target.len() { + return Err("a BPS action extends beyond the target ROM size".to_string()); + } + + match mode { + 0 => { + let start = target.len(); + let end = start + .checked_add(length) + .filter(|end| *end <= source.len()) + .ok_or_else(|| "a BPS source-read action extends beyond the ROM".to_string())?; + target.extend_from_slice(&source[start..end]); + } + 1 => { + let end = cursor + .checked_add(length) + .filter(|end| *end <= footer_start) + .ok_or_else(|| "a BPS target-read action is incomplete".to_string())?; + target.extend_from_slice(&patch[cursor..end]); + cursor = end; + } + 2 => { + let encoded_offset = read_bps_number(patch, &mut cursor, footer_start)?; + let start = update_relative_offset(&mut source_relative_offset, encoded_offset)?; + let end = start + .checked_add(length) + .filter(|end| *end <= source.len()) + .ok_or_else(|| "a BPS source-copy action extends beyond the ROM".to_string())?; + target.extend_from_slice(&source[start..end]); + source_relative_offset = end as i128; + } + 3 => { + let encoded_offset = read_bps_number(patch, &mut cursor, footer_start)?; + let start = update_relative_offset(&mut target_relative_offset, encoded_offset)?; + let end = start + .checked_add(length) + .ok_or_else(|| "the BPS target-copy action is too large".to_string())?; + for index in start..end { + let value = *target.get(index).ok_or_else(|| { + "a BPS target-copy action refers to data not yet written".to_string() + })?; + target.push(value); + } + target_relative_offset = end as i128; + } + _ => unreachable!(), + } + } + + if cursor != footer_start { + return Err("the BPS patch has unexpected data before its checksums".to_string()); + } + + let expected_source_crc = read_u32_le(patch, footer_start)?; + let expected_target_crc = read_u32_le(patch, footer_start + 4)?; + let expected_patch_crc = read_u32_le(patch, footer_start + 8)?; + if crc32(&source) != expected_source_crc { + return Err("the selected ROM does not match the BPS patch checksum".to_string()); + } + if crc32(&target) != expected_target_crc { + return Err("the BPS patch produced a ROM with an invalid checksum".to_string()); + } + if crc32(&patch[..patch.len() - 4]) != expected_patch_crc { + return Err("the BPS patch has an invalid checksum".to_string()); + } + + Ok(target) +} + +fn read_bps_number(data: &[u8], cursor: &mut usize, end: usize) -> Result { + let mut value = 0_usize; + let mut shift = 1_usize; + loop { + if *cursor >= end { + return Err("the BPS patch ended unexpectedly".to_string()); + } + let byte = data[*cursor]; + *cursor += 1; + value = + value + .checked_add(usize::from(byte & 0x7f).checked_mul(shift).ok_or_else(|| { + "the BPS patch contains a number that is too large".to_string() + })?) + .ok_or_else(|| "the BPS patch contains a number that is too large".to_string())?; + if byte & 0x80 != 0 { + return Ok(value); + } + shift = shift + .checked_mul(128) + .ok_or_else(|| "the BPS patch contains a number that is too large".to_string())?; + value = value + .checked_add(shift) + .ok_or_else(|| "the BPS patch contains a number that is too large".to_string())?; + } +} + +fn update_relative_offset(offset: &mut i128, encoded: usize) -> Result { + let magnitude = (encoded >> 1) as i128; + let change = if encoded & 1 == 0 { + magnitude + } else { + -magnitude + }; + *offset = offset + .checked_add(change) + .ok_or_else(|| "a BPS relative offset is too large".to_string())?; + usize::try_from(*offset).map_err(|_| "a BPS relative offset points before the ROM".to_string()) +} + +fn read_u32_le(data: &[u8], cursor: usize) -> Result { + let bytes: [u8; 4] = data + .get(cursor..cursor + 4) + .ok_or_else(|| "the BPS patch is missing a checksum".to_string())? + .try_into() + .expect("checksum slices contain four bytes"); + Ok(u32::from_le_bytes(bytes)) +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = u32::MAX; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::{apply_bps_bytes, apply_ips_bytes, crc32}; + + #[test] + fn applies_regular_and_rle_records() { + let patch = b"PATCH\x00\x00\x01\x00\x02\xaa\xbb\x00\x00\x04\x00\x00\x00\x03\xccEOF"; + let output = apply_ips_bytes(patch, vec![0; 2]).unwrap(); + assert_eq!(output, vec![0, 0xaa, 0xbb, 0, 0xcc, 0xcc, 0xcc]); + } + + #[test] + fn rejects_a_missing_header() { + let error = apply_ips_bytes(b"NOT AN IPS PATCH", vec![]).unwrap_err(); + assert!(error.contains("not a valid IPS")); + } + + #[test] + fn honors_the_optional_truncate_size() { + let patch = b"PATCHEOF\x00\x00\x02"; + let output = apply_ips_bytes(patch, vec![1, 2, 3, 4]).unwrap(); + assert_eq!(output, vec![1, 2]); + } + + #[test] + fn applies_all_bps_action_types() { + let source = b"abcdef"; + let mut actions = Vec::new(); + encode_bps_number(4, &mut actions); // SourceRead 2 + encode_bps_number(5, &mut actions); // TargetRead 2 + actions.extend_from_slice(b"XY"); + encode_bps_number(4, &mut actions); // SourceRead 2 + encode_bps_number(7, &mut actions); // TargetCopy 2 + encode_bps_number(4, &mut actions); // Move to target offset 2 + + let patch = bps_patch(source, b"abXYefXY", &actions); + assert_eq!( + apply_bps_bytes(&patch, source.to_vec()).unwrap(), + b"abXYefXY" + ); + } + + #[test] + fn applies_forward_and_backward_bps_source_copies() { + let source = b"abcdef"; + let mut actions = Vec::new(); + encode_bps_number(6, &mut actions); // SourceCopy 2 + encode_bps_number(8, &mut actions); // Move to source offset 4 + encode_bps_number(6, &mut actions); // SourceCopy 2 + encode_bps_number(13, &mut actions); // Move back from 6 to 0 + + let patch = bps_patch(source, b"efab", &actions); + assert_eq!(apply_bps_bytes(&patch, source.to_vec()).unwrap(), b"efab"); + } + + #[test] + fn rejects_a_rom_with_the_wrong_bps_checksum() { + let source = b"abcd"; + let mut actions = Vec::new(); + encode_bps_number(12, &mut actions); // SourceRead 4 + let patch = bps_patch(source, source, &actions); + + let error = apply_bps_bytes(&patch, b"abce".to_vec()).unwrap_err(); + assert!(error.contains("does not match")); + } + + fn bps_patch(source: &[u8], target: &[u8], actions: &[u8]) -> Vec { + let mut patch = b"BPS1".to_vec(); + encode_bps_number(source.len(), &mut patch); + encode_bps_number(target.len(), &mut patch); + encode_bps_number(0, &mut patch); + patch.extend_from_slice(actions); + patch.extend_from_slice(&crc32(source).to_le_bytes()); + patch.extend_from_slice(&crc32(target).to_le_bytes()); + let patch_crc = crc32(&patch); + patch.extend_from_slice(&patch_crc.to_le_bytes()); + patch + } + + fn encode_bps_number(mut value: usize, output: &mut Vec) { + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + output.push(byte | 0x80); + return; + } + output.push(byte); + value -= 1; + } + } +} diff --git a/src/popover.rs b/src/popover.rs deleted file mode 100644 index 245b47f..0000000 --- a/src/popover.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::rc::Rc; -use futures::executor::block_on; -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; -use gtk4::{ - AboutDialog, - gdk::Paintable, - glib::*, - ApplicationWindow, - HeaderBar, - Button, - Window, - Popover, - Builder, - License, - IconTheme, - IconLookupFlags, - TextDirection, - MenuButton, - TextView, - Box, - Orientation, -}; - -#[derive(Debug, Clone, Downgrade)] -pub struct CustomPopoverMenu { - pub popover: Popover, - pub pop_box: Box, - pub test_text_view:Button, - pub subtest_text_view: Button, -} - -impl CustomPopoverMenu { - pub fn new() -> CustomPopoverMenu { - CustomPopoverMenu { - popover: Popover::new(), - pop_box: Box::new(Orientation::Vertical, 0), - test_text_view: Button::new(), - subtest_text_view: Button::new(), - } - } - - pub fn set_texts(&self) { - self.test_text_view.set_label("About"); - self.subtest_text_view.set_label("Psyche!"); - self.pop_box.append(&self.test_text_view); - self.pop_box.append(&self.subtest_text_view); - self.pop_box.set_visible(true); - self.popover.set_child(Some(&self.pop_box)); - } - - pub fn click_about_button(&self, - //window: Rc - ) { - self.test_text_view.connect_clicked(clone!(@strong self as this => - move |_| { - let about_dialog = AboutDialog::builder() - .program_name("SNESPatcher") - .version("0.1.0") - .comments("GTK4 app to patch your SNES ROMs!") - .authors(["Wyatt J. Miller".to_string()].to_vec()) - .documenters(["Wyatt J. Miller".to_string()].to_vec()) - .license_type(License::Mpl20) - .website("https://scm.wyattjmiller.com/wymiller/snespatcher") - .website_label("Website?") - .modal(false) - .can_focus(true) - //.child(&window) - .build(); - about_dialog.show(); - })); - } -} \ No newline at end of file diff --git a/src/rom.rs b/src/rom.rs new file mode 100644 index 0000000..f9a042f --- /dev/null +++ b/src/rom.rs @@ -0,0 +1,247 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct RomInfo { + path: PathBuf, + file_size: usize, + has_copier_header: bool, + header: Option, +} + +struct SnesHeader { + title: String, + mapping: &'static str, + region: &'static str, + version: u8, +} + +#[derive(Clone, Copy)] +enum Mapping { + Low, + High, + ExtendedHigh, +} + +impl RomInfo { + pub fn read(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| format!("could not read the ROM ({error})"))?; + let has_copier_header = bytes.len() >= 512 && bytes.len() % 0x8000 == 512; + let rom = if has_copier_header { + &bytes[512..] + } else { + &bytes[..] + }; + + Ok(Self { + path: path.to_path_buf(), + file_size: bytes.len(), + has_copier_header, + header: find_header(rom), + }) + } + + pub fn summary(&self) -> String { + let file_name = self + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Selected file"); + let location = self + .path + .parent() + .map(|parent| parent.display().to_string()) + .filter(|parent| !parent.is_empty()) + .unwrap_or_else(|| "Unknown".to_string()); + let copier_header = if self.has_copier_header { "Yes" } else { "No" }; + + let mut details = vec![ + format!("File: {file_name}"), + format!("Location: {location}"), + format!("Size: {}", format_file_size(self.file_size)), + format!("Copier header: {copier_header}"), + ]; + + if let Some(header) = &self.header { + details.splice( + 0..0, + [ + format!("Game title: {}", header.title), + format!("Mapping: {}", header.mapping), + format!("Region: {}", header.region), + format!("Revision: 1.{}", header.version), + String::new(), + ], + ); + } else { + details.splice( + 0..0, + [ + "Game title: Not found".to_string(), + "SNES header: Not recognized".to_string(), + String::new(), + ], + ); + } + + details.join("\n") + } +} + +fn find_header(rom: &[u8]) -> Option { + let candidates = [ + (0x7fc0, Mapping::Low), + (0xffc0, Mapping::High), + (0x40ffc0, Mapping::ExtendedHigh), + ]; + + candidates + .iter() + .filter_map(|(offset, mapping)| parse_header(rom, *offset, *mapping)) + .max_by_key(|(score, _)| *score) + .filter(|(score, _)| *score >= 5) + .map(|(_, header)| header) +} + +fn parse_header(rom: &[u8], offset: usize, mapping: Mapping) -> Option<(i32, SnesHeader)> { + let bytes = rom.get(offset..offset + 0x40)?; + let title = parse_title(&bytes[..21])?; + let map_mode = bytes[0x15]; + let checksum_complement = u16::from_le_bytes([bytes[0x1c], bytes[0x1d]]); + let checksum = u16::from_le_bytes([bytes[0x1e], bytes[0x1f]]); + let reset_vector = u16::from_le_bytes([bytes[0x3c], bytes[0x3d]]); + + let mut score = 0; + if mapping_matches(map_mode, mapping) { + score += 3; + } + if checksum != 0 && checksum != u16::MAX && checksum ^ checksum_complement == u16::MAX { + score += 5; + } + if reset_vector >= 0x8000 { + score += 3; + } + + Some(( + score, + SnesHeader { + title, + mapping: mapping_name(mapping), + region: region_name(bytes[0x19]), + version: bytes[0x1b], + }, + )) +} + +fn parse_title(bytes: &[u8]) -> Option { + if bytes.iter().any(|byte| !matches!(*byte, 0 | 0x20..=0x7e)) { + return None; + } + + let end = bytes + .iter() + .rposition(|byte| *byte != 0 && *byte != b' ') + .map(|position| position + 1)?; + Some(String::from_utf8_lossy(&bytes[..end]).to_string()) +} + +fn mapping_matches(map_mode: u8, mapping: Mapping) -> bool { + match mapping { + Mapping::Low => matches!(map_mode & 0x0f, 0 | 2 | 3), + Mapping::High => map_mode & 0x0f == 1, + Mapping::ExtendedHigh => map_mode & 0x0f == 5, + } +} + +fn mapping_name(mapping: Mapping) -> &'static str { + match mapping { + Mapping::Low => "LoROM", + Mapping::High => "HiROM", + Mapping::ExtendedHigh => "ExHiROM", + } +} + +fn region_name(region: u8) -> &'static str { + match region { + 0 => "Japan", + 1 => "USA / Canada", + 2 => "Europe / Oceania / Asia", + 3 => "Sweden", + 4 => "Finland", + 5 => "Denmark", + 6 => "France", + 7 => "Netherlands", + 8 => "Spain", + 9 => "Germany / Austria / Switzerland", + 10 => "Italy", + 11 => "Hong Kong / China", + 12 => "Indonesia", + 13 => "South Korea", + _ => "Unknown", + } +} + +fn format_file_size(bytes: usize) -> String { + let exact = format_integer(bytes); + if bytes < 1024 { + return format!("{exact} bytes"); + } + + const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"]; + let mut size = bytes as f64 / 1024.0; + let mut unit = UNITS[0]; + for candidate in &UNITS[1..] { + if size < 1024.0 { + break; + } + size /= 1024.0; + unit = candidate; + } + format!("{size:.2} {unit} ({exact} bytes)") +} + +fn format_integer(value: usize) -> String { + let digits = value.to_string(); + let mut result = String::with_capacity(digits.len() + digits.len() / 3); + for (index, character) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + result.push(','); + } + result.push(character); + } + result +} + +#[cfg(test)] +mod tests { + use super::{find_header, format_file_size}; + + #[test] + fn reads_a_lorom_header() { + let mut rom = vec![0; 0x8000]; + let header = &mut rom[0x7fc0..0x8000]; + header[..21].copy_from_slice(b"SUPER MARIOWORLD "); + header[0x15] = 0x20; + header[0x19] = 1; + header[0x1b] = 2; + header[0x1c..0x1e].copy_from_slice(&0x4321_u16.to_le_bytes()); + header[0x1e..0x20].copy_from_slice(&0xbcde_u16.to_le_bytes()); + header[0x3c..0x3e].copy_from_slice(&0x8000_u16.to_le_bytes()); + + let header = find_header(&rom).expect("the header should be recognized"); + assert_eq!(header.title, "SUPER MARIOWORLD"); + assert_eq!(header.mapping, "LoROM"); + assert_eq!(header.region, "USA / Canada"); + assert_eq!(header.version, 2); + } + + #[test] + fn rejects_data_without_a_snes_header() { + assert!(find_header(&vec![0; 0x10000]).is_none()); + } + + #[test] + fn formats_file_sizes_for_people() { + assert_eq!(format_file_size(512), "512 bytes"); + assert_eq!(format_file_size(1_048_576), "1.00 MiB (1,048,576 bytes)"); + } +} diff --git a/src/titlebar.rs b/src/titlebar.rs deleted file mode 100644 index d9e3ded..0000000 --- a/src/titlebar.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::rc::Rc; -use futures::executor::block_on; -use gtk4::{prelude::*}; -use gtk4::subclass::prelude::*; -use gtk4::{ - gdk::Paintable, - glib::*, - ApplicationWindow, - HeaderBar, - Button, - Window, - Popover, - Builder, - IconTheme, - IconLookupFlags, - TextDirection, - MenuButton, -}; - -use crate::popover::CustomPopoverMenu; - -#[derive(Debug, Downgrade)] -pub struct CustomTitleBar { - pub window: Window, - pub titlebar: HeaderBar, - pub run_button: Button, - pub menu_button: MenuButton, - pub popover_menu: CustomPopoverMenu, -} - -impl CustomTitleBar { - pub fn new(pop: &CustomPopoverMenu) -> CustomTitleBar { - // loading PopoverMenu logic goes here - CustomTitleBar { - window: Window::new(), - titlebar: HeaderBar::new(), - run_button: Button::new(), - menu_button: MenuButton::new(), - popover_menu: pop.clone(), - } - } - - pub fn set_buttons(&self) { - // set labels - self.run_button.set_label("Apply Patch"); - //self.menu_button.set_label("Menu"); - - // set css class; it turns blue! - self.run_button.set_css_classes(&["run_btn"]); - - // set visibility - self.run_button.set_visible(true); - self.menu_button.set_visible(true); - - // symbolic - // let menu_button = IconTheme::new(); - // menu_button.lookup_icon("open-menu-symbolic", &[], 16, 1, TextDirection::Ltr, IconLookupFlags::empty()); - self.menu_button.set_popover(Some(&self.popover_menu.popover)); - - } - - pub fn click_run_button(&self) { - self.run_button.connect_clicked(move |button| { - button.set_label("Avery is awesome!"); - }); - } - - pub fn click_menu(&self) { - // self.menu_button.connect_clicked(clone!(@weak self as this => - // move |_| { - // this.popover.show(); - // })); - } - - pub fn set_window(&self, window: &ApplicationWindow) { - window.set_titlebar(Some(&self.titlebar)); - } - - pub fn set_titlebar(&self) { - self.titlebar.pack_start(&self.run_button); - self.titlebar.pack_end(&self.menu_button); - self.titlebar.set_css_classes(&["headerbar"]) - } - - pub fn set_popover(&self) { - // huh? why is this method here again? - } -} \ No newline at end of file