add: refactored everything

This commit is contained in:
2026-08-26 21:33:44 -04:00
parent b2efd6917d
commit b471d6e22f
16 changed files with 1268 additions and 608 deletions
+1
View File
@@ -0,0 +1 @@
use flake
Generated
+1 -99
View File
@@ -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",
]
-5
View File
@@ -8,8 +8,3 @@ edition = "2018"
[dependencies]
gtk4 = "0.4.7"
glib = "*"
gio = "*"
gdk = "*"
futures = "0.3"
byteorder = "*"
+14 -6
View File
@@ -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!
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
```
+10 -8
View File
@@ -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");
}
Generated
+27
View File
@@ -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
}
+79
View File
@@ -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
];
};
});
};
}
+7 -5
View File
@@ -1,15 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<menu id='app-menu'>
<menu id="app-menu">
<section>
<item>
<attribute name='label' translatable='yes'>_About SNES Patcher</attribute>
<attribute name='action'>app.about</attribute>
<attribute name="label" translatable="yes">_About SNES Patcher</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
<section>
<item>
<attribute name='label' translatable='yes'>_Quit</attribute>
<attribute name='action'>app.quit</attribute>
<attribute name="label" translatable="yes">_Quit</attribute>
<attribute name="action">app.quit</attribute>
</item>
</section>
</menu>
+14 -17
View File
@@ -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;
}
-196
View File
@@ -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);
}
}
+518 -88
View File
@@ -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<RefCell<Option<PathBuf>>>,
patch: Rc<RefCell<Option<PathBuf>>>,
format: Rc<Cell<patch::PatchFormat>>,
}
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(
&gtk4::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<ApplicationWindow> = 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<W: IsA<gtk4::Window>>(window: Rc<W>) {
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::<gio::MenuModel>("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", &["<primary>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<RefCell<Option<PathBuf>>>,
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}")
}
-4
View File
@@ -1,4 +0,0 @@
enum PatchType {
Ips,
Bps,
}
+350 -19
View File
@@ -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::<BigEndian>().unwrap());
},
false => panic!("\u{1F926}"),
fn apply_ips_bytes(patch: &[u8], mut rom: Vec<u8>) -> Result<Vec<u8>, String> {
if !patch.starts_with(b"PATCH") {
return Err("the selected file is not a valid IPS patch".to_string());
}
}
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<u8>, 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<usize, String> {
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<usize, String> {
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<u8>) -> Result<Vec<u8>, 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<usize, String> {
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<usize, String> {
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<u32, String> {
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<u8> {
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<u8>) {
loop {
let byte = (value & 0x7f) as u8;
value >>= 7;
if value == 0 {
output.push(byte | 0x80);
return;
}
output.push(byte);
value -= 1;
}
}
}
-73
View File
@@ -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<ApplicationWindow>
) {
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();
}));
}
}
+247
View File
@@ -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<SnesHeader>,
}
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<Self, String> {
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<SnesHeader> {
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<String> {
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)");
}
}
-88
View File
@@ -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?
}
}