2 Commits
Author SHA1 Message Date
wymiller 0088e52398 tried to add accelerators/kb shortcuts 2022-05-24 10:44:19 -04:00
wymiller 20cc623431 added a real menu 2022-05-24 10:43:44 -04:00
20 changed files with 626 additions and 1561 deletions
-1
View File
@@ -1 +0,0 @@
use flake
-40
View File
@@ -1,40 +0,0 @@
name: Update flake lock
on:
schedule:
# Gitea cron schedules use UTC. 07:00 UTC is 03:00 EDT.
- cron: "0 7 * * 0"
workflow_dispatch:
permissions:
contents: write
jobs:
update-flake-lock:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install upstream Nix
uses: cachix/install-nix-action@v31
- name: Update flake lock
run: nix flake update
- name: Commit and push changes
shell: bash
run: |
set -euo pipefail
if git diff --quiet -- flake.lock; then
echo "flake.lock is already up to date"
exit 0
fi
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.localhost"
git add flake.lock
git commit -m "chore: update flake lock"
git push
Generated
+99 -1
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 3
[[package]] [[package]]
name = "anyhow" name = "anyhow"
@@ -20,6 +20,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "byteorder"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.15.11" version = "0.15.11"
@@ -63,6 +69,21 @@ dependencies = [
"rustc_version", "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]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.15" version = "0.3.15"
@@ -70,6 +91,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -95,6 +117,25 @@ version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" 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]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.15" version = "0.3.15"
@@ -108,13 +149,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"proc-macro-hack",
"proc-macro-nested",
"slab", "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]] [[package]]
name = "gdk-pixbuf" name = "gdk-pixbuf"
version = "0.15.11" version = "0.15.11"
@@ -141,6 +205,23 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "gdk4" name = "gdk4"
version = "0.4.7" version = "0.4.7"
@@ -491,6 +572,18 @@ dependencies = [
"version_check", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.27" version = "1.0.27"
@@ -567,6 +660,11 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
name = "snespatcher" name = "snespatcher"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"byteorder",
"futures",
"gdk",
"gio",
"glib",
"gtk4", "gtk4",
] ]
+5
View File
@@ -8,3 +8,8 @@ edition = "2018"
[dependencies] [dependencies]
gtk4 = "0.4.7" gtk4 = "0.4.7"
glib = "*"
gio = "*"
gdk = "*"
futures = "0.3"
byteorder = "*"
+6 -14
View File
@@ -1,24 +1,16 @@
# SNES Patcher # SNES Patcher
Create patched copies of Super Nintendo ROMs with IPS or BPS patches. Patch your SNES games with IPS or BPS patches, starring SNES Patcher!
## Features ## Features
- Native GTK 4 interface designed for GNOME - Written with GTK4, not GTK3
- Safe workflow that preserves the original ROM - Written in Rust, not C/C++
- IPS records, RLE records, and optional truncation support - Simple, intuitive user interface
- BPS actions, metadata, and checksum validation
## What is this? ## What is this?
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. SNES Patcher is a simple GTK4 application where you can patch your SNES ROMs with IPS or BPS patches.
## Why? ## 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. 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? ## 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
```
+8 -10
View File
@@ -1,15 +1,13 @@
use std::process::Command; use std::process::Command;
fn main() { fn main() {
println!("cargo:rerun-if-changed=resources/resources.xml"); // let _ = Command::new("sh")
println!("cargo:rerun-if-changed=resources/style.css"); // .args(&["-c", "cd resources && gtk4-builder-tool simplify --3to4 snespatcher.ui --replace"])
println!("cargo:rerun-if-changed=resources/appmenu.xml"); // .output()
// .expect("failed to execute process");
let status = Command::new("glib-compile-resources") let _ = Command::new("sh")
.arg("resources.xml") .args(&["-c", "cd resources && glib-compile-resources resources.xml"])
.current_dir("resources") .output()
.status() .expect("failed to execute process");
.expect("failed to run glib-compile-resources");
assert!(status.success(), "failed to compile application resources");
} }
-11
View File
@@ -1,11 +0,0 @@
[Desktop Entry]
Type=Application
Name=SNES Patcher
GenericName=ROM Patcher
Comment=Apply IPS and BPS patches to Super Nintendo ROMs
Exec=snespatcher
Icon=com.wyattjmiller.snespatcher
Terminal=false
Categories=Utility;
Keywords=SNES;ROM;IPS;BPS;Patch;
StartupNotify=true
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.wyattjmiller.snespatcher</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MPL-2.0</project_license>
<name>SNES Patcher</name>
<summary>Apply IPS and BPS patches to Super Nintendo ROMs</summary>
<description>
<p>
SNES Patcher creates patched copies of Super Nintendo ROMs
while preserving the original file.
</p>
</description>
<launchable type="desktop-id">
com.wyattjmiller.snespatcher.desktop
</launchable>
<url type="homepage">
https://scm.wyattjmiller.com/wymiller/snespatcher
</url>
<content_rating type="oars-1.1"/>
</component>
@@ -1,102 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#565b66"/>
<stop offset="1" stop-color="#353944"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4b505b"/>
<stop offset="1" stop-color="#373b45"/>
</linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffd36a"/>
<stop offset="1" stop-color="#e9a834"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#000" flood-opacity=".28"/>
</filter>
<filter id="arrowShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity=".35"/>
</filter>
</defs>
<!-- Cartridge -->
<g filter="url(#shadow)">
<!-- main shell -->
<path d="
M55 26
Q44 26 39 33
Q35 39 35 49
L35 72
Q35 79 29 84
L24 88
L24 100
L30 104
Q35 108 35 116
L35 190
Q35 203 44 212
Q51 220 63 220
L193 220
Q205 220 212 212
Q221 203 221 190
L221 116
Q221 108 226 104
L232 100
L232 88
L227 84
Q221 79 221 72
L221 49
Q221 39 216 33
Q211 26 200 26
Z"
fill="url(#body)" stroke="#2b2e36" stroke-width="5" stroke-linejoin="round"/>
<!-- top grooves -->
<rect x="61" y="49" width="134" height="7" rx="3.5" fill="#292d35"/>
<rect x="61" y="63" width="134" height="7" rx="3.5" fill="#292d35"/>
<!-- center inset -->
<rect x="64" y="88" width="128" height="101" rx="15"
fill="#30343d" stroke="#252932" stroke-width="5"/>
<rect x="70" y="94" width="116" height="89" rx="11"
fill="url(#panel)" stroke="#626874" stroke-width="2"/>
<!-- tiny triangle notch -->
<path d="M118 194 L138 194 L128 205 Z" fill="#252932"/>
<!-- gold contacts -->
<rect x="61" y="207" width="134" height="23" rx="4"
fill="url(#gold)" stroke="#7e581c" stroke-width="3"/>
<g stroke="#b77920" stroke-width="3">
<line x1="78" y1="208" x2="78" y2="229"/>
<line x1="95" y1="208" x2="95" y2="229"/>
<line x1="112" y1="208" x2="112" y2="229"/>
<line x1="129" y1="208" x2="129" y2="229"/>
<line x1="146" y1="208" x2="146" y2="229"/>
<line x1="163" y1="208" x2="163" y2="229"/>
<line x1="180" y1="208" x2="180" y2="229"/>
</g>
</g>
<!-- down arrow -->
<path d="
M119 112
Q119 106 125 106
L131 106
Q137 106 137 112
L137 144
L151 130
Q155 126 159 130
L164 135
Q168 139 164 143
L132 175
Q128 179 124 175
L92 143
Q88 139 92 135
L97 130
Q101 126 105 130
L119 144
Z"
fill="#f2f2f2" stroke="#d7d7d7" stroke-width="2" stroke-linejoin="round"
filter="url(#arrowShadow)"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1787900134,
"narHash": "sha256-VYXO0XZlgj06dxJZRhrD3WoSsvq/c7+/Akyoa22pefw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "83199d0d373dd3ac2b9a1996b1d0263f76ab7a4c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-109
View File
@@ -1,109 +0,0 @@
{
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
{
formatter = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
pkgs.nixfmt-rfc-style
);
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
];
postInstall = ''
install -Dm444 \
data/com.wyattjmiller.snespatcher.desktop \
$out/share/applications/com.wyattjmiller.snespatcher.desktop
install -Dm444 \
data/com.wyattjmiller.snespatcher.metainfo.xml \
$out/share/metainfo/com.wyattjmiller.snespatcher.metainfo.xml
install -Dm444 \
data/icons/hicolor/scalable/apps/com.wyattjmiller.snespatcher.svg \
$out/share/icons/hicolor/scalable/apps/com.wyattjmiller.snespatcher.svg
'';
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
desktop-file-utils
appstream
];
};
}
);
};
}
+5 -24
View File
@@ -1,34 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<interface> <interface>
<requires lib="gtk" version="4.0"/> <requires lib="gtk" version="4.0"/>
<menu id="app-menu"> <menu id='app-menu'>
<section>
<submenu>
<attribute name="label" translatable="yes">_Appearance</attribute>
<section>
<item>
<attribute name="label" translatable="yes">Follow _System</attribute>
<attribute name="action">app.theme</attribute>
<attribute name="target">system</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Dark</attribute>
<attribute name="action">app.theme</attribute>
<attribute name="target">dark</attribute>
</item>
</section>
</submenu>
</section>
<section> <section>
<item> <item>
<attribute name="label" translatable="yes">_About SNES Patcher</attribute> <attribute name='label' translatable='yes'>_About SNES Patcher</attribute>
<attribute name="action">app.about</attribute> <attribute name='action'>app.about</attribute>
</item> </item>
</section>
<section>
<item> <item>
<attribute name="label" translatable="yes">_Quit</attribute> <attribute name='label' translatable='yes'>_Quit</attribute>
<attribute name="action">app.quit</attribute> <attribute name='action'>app.quit</attribute>
</item> </item>
</section> </section>
</menu> </menu>
+16 -20
View File
@@ -1,30 +1,26 @@
.main-content { .run_btn:focus {
min-width: 356px; background: #1c71d8;
color: #ffffff;
} }
.heading { .run_btn:hover {
font-weight: 600; background: #3584e4;
} }
.selection-row { .run_btn:active {
min-height: 48px; background: #1a5fb4;
padding: 10px 12px;
border-radius: 0;
} }
.format-row { .run_btn {
min-height: 48px; background: #1a5fb4;
padding: 10px 12px;
} }
.intro-title { .run_btn:disabled {
font-size: 1.5rem; background: #3d3846;
font-weight: 700; color: #5e5c64;
} }
headerbar.titlebar { .headerbar {
background-color: transparent; background: #353535;
background-image: none; border: 0;
border-bottom: none; }
box-shadow: none;
}
+196
View File
@@ -0,0 +1,196 @@
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);
}
}
+92 -578
View File
@@ -1,607 +1,121 @@
mod patch; mod patch;
mod rom; mod titlebar;
mod boxes;
mod menu;
mod popover;
use std::cell::{Cell, RefCell}; use gio::SimpleAction;
use std::path::{Path, PathBuf}; use glib::clone;
use titlebar::CustomTitleBar;
use boxes::*;
use popover::CustomPopoverMenu;
use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
use gtk4::gio; use gtk4::{prelude::*, Window};
use gtk4::glib;
use gtk4::glib::ToVariant;
use gtk4::pango::EllipsizeMode;
use gtk4::prelude::*;
use gtk4::{ use gtk4::{
AboutDialog, Align, Application, ApplicationWindow, Box as GtkBox, Button, ButtonsType, gdk::{
DropDown, FileChooserAction, FileChooserNative, FileFilter, Frame, HeaderBar, Image, Display
Justification, Label, License, MenuButton, MessageDialog, Orientation, ResponseType, Separator, },
gio::MenuModel,
Application,
ApplicationWindow,
Builder,
StyleContext,
}; };
const APP_ID: &str = "com.wyattjmiller.snespatcher";
const APP_NAME: &str = "SNES Patcher";
const THEME_SYSTEM: &str = "system";
const THEME_DARK: &str = "dark";
#[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() { fn main() {
glib::set_application_name(APP_NAME); 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.connect_activate(build_ui);
app.set_accels_for_action("win.close", &["<Ctrl>W"]);
app.run(); app.run();
} }
fn build_ui(app: &Application) { fn build_ui(app: &Application) {
register_resources(); let resources_bytes = include_bytes!("../resources/resources.gresource");
let resource_data = glib::Bytes::from(&resources_bytes[..]);
let window = ApplicationWindow::builder() let res = gio::Resource::from_data(&resource_data).unwrap();
.application(app) gio::resources_register(&res);
.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("intro-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(); let provider = gtk4::CssProvider::new();
provider.load_from_resource("/com/wyattjmiller/snespatcher/style.css"); provider.load_from_resource("/com/wyattjmiller/snespatcher/style.css");
gtk4::StyleContext::add_provider_for_display( StyleContext::add_provider_for_display(
&gtk4::gdk::Display::default().expect("Could not connect to a display"), &Display::default().expect("Error initializing CSS provider!"),
&provider, &provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );
}
fn build_header_bar( let app_window: Rc<ApplicationWindow> = Rc::new(ApplicationWindow::new(app));
app: &Application, let file_picker_window: Window = Window::new();
window: &ApplicationWindow, let popover_menu: CustomPopoverMenu = CustomPopoverMenu::new();
apply_button: &Button, let header_bar: CustomTitleBar = CustomTitleBar::new();
rom_info_button: &Button, let app_box: AppBox = AppBox::new();
) -> HeaderBar { let patch_box: PatchBox = PatchBox::new(&app_window);
let builder = gtk4::Builder::from_resource("/com/wyattjmiller/snespatcher/appmenu.xml"); let rom_box: RomBox = RomBox::new(&app_window);
let menu_model = builder
.object::<gio::MenuModel>("app-menu") // set popover_menu methods
.expect("Application menu is missing from resources"); popover_menu.set_texts();
popover_menu.click_about_button();
let menu_button = MenuButton::new(); // TODO: Set popover textviews and put them in a separate file
menu_button.set_icon_name("open-menu-symbolic"); // set header bar methods
menu_button.set_menu_model(Some(&menu_model)); header_bar.set_actions();
menu_button.set_tooltip_text(Some("Main Menu")); header_bar.set_buttons();
header_bar.set_titlebar();
header_bar.set_window(&app_window);
header_bar.click_run_button();
// set patch box methods
patch_box.set_label();
patch_box.set_button();
patch_box.set_box();
patch_box.click_button(file_picker_window.clone());
let header_bar = HeaderBar::new(); // set rom box methods
header_bar.set_show_title_buttons(true); rom_box.set_label();
header_bar.pack_start(apply_button); rom_box.set_button();
header_bar.pack_start(rom_info_button); rom_box.set_box();
header_bar.pack_end(&menu_button); rom_box.click_button(file_picker_window.clone());
// set app box methods
app_box.set_box(&rom_box, &patch_box);
add_theme_action(app); // set apps default methods
app_window.set_title(Some("SNES Patcher"));
let about = gio::SimpleAction::new("about", None); app_window.set_resizable(false);
about.connect_activate(glib::clone!(@weak window => move |_, _| { app_window.set_child(Some(&app_box.app_box));
let dialog = AboutDialog::builder()
.transient_for(&window) // keyboard shortcuts
.program_name(APP_NAME) let action_close = SimpleAction::new("close", None);
.logo_icon_name(APP_ID) action_close.connect_activate(clone!(@weak app_window => move |_, _| {
.modal(true) app_window.close();
.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); app_window.add_action(&action_close);
let quit = gio::SimpleAction::new("quit", None); // show the app
quit.connect_activate(glib::clone!(@weak app => move |_, _| app.quit())); app_window.show();
app.add_action(&quit);
app.set_accels_for_action("app.quit", &["<primary>q"]);
header_bar
} }
fn add_theme_action(app: &Application) { async fn dialog<W: IsA<gtk4::Window>>(window: Rc<W>) {
let preference = load_theme_preference(); let question_dialog = gtk4::MessageDialog::builder()
let settings = gtk4::Settings::default().expect("Could not access GTK settings"); .transient_for(&*window)
apply_theme_preference(&settings, &preference); .modal(true)
.buttons(gtk4::ButtonsType::OkCancel)
.text("What is your answer?")
.build();
let theme = gio::SimpleAction::new_stateful( let answer = question_dialog.run_future().await;
"theme", question_dialog.close();
Some(glib::VariantTy::STRING),
&preference.to_variant(),
);
theme.connect_activate(move |action, parameter| {
let Some(preference) = parameter.and_then(|value| value.get::<String>()) else {
return;
};
if preference != THEME_SYSTEM && preference != THEME_DARK {
return;
}
apply_theme_preference(&settings, &preference); let info_dialog = gtk4::MessageDialog::builder()
action.set_state(&preference.to_variant()); .transient_for(&*window)
save_theme_preference(&preference); .modal(true)
}); .buttons(gtk4::ButtonsType::Close)
app.add_action(&theme); .text("You answered")
} .secondary_text(&format!("Your answer: {:?}", answer))
.build();
fn apply_theme_preference(settings: &gtk4::Settings, preference: &str) {
settings.set_gtk_application_prefer_dark_theme(preference == THEME_DARK); info_dialog.run_future().await;
} info_dialog.close();
fn theme_preference_path() -> PathBuf {
glib::user_config_dir().join(APP_ID).join("theme")
}
fn load_theme_preference() -> String {
match std::fs::read_to_string(theme_preference_path()) {
Ok(preference) if preference.trim() == THEME_DARK => THEME_DARK.to_string(),
_ => THEME_SYSTEM.to_string(),
}
}
fn save_theme_preference(preference: &str) {
let path = theme_preference_path();
let Some(parent) = path.parent() else {
return;
};
if let Err(error) = std::fs::create_dir_all(parent) {
eprintln!("Could not create the preferences directory: {error}");
return;
}
if let Err(error) = std::fs::write(path, preference) {
eprintln!("Could not save the theme preference: {error}");
}
}
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
@@ -0,0 +1,4 @@
enum PatchType {
Ips,
Bps,
}
+19 -350
View File
@@ -1,354 +1,23 @@
use std::convert::{TryFrom, TryInto}; use std::fs::File;
use std::fs; use std::io::{Read, Seek, SeekFrom};
use std::path::Path; use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] use byteorder::*;
pub enum PatchFormat {
Ips,
Bps,
}
pub fn apply( pub fn apply_ips(ips_file: PathBuf, binary: PathBuf) {
format: PatchFormat, match ips_file.exists() || binary.exists() {
patch_path: &Path, true => {
rom_path: &Path, let mut ips = File::open(ips_file).unwrap();
destination: &Path, let mut bin = File::open(binary).unwrap();
) -> 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})"))
}
fn apply_ips_bytes(patch: &[u8], mut rom: Vec<u8>) -> Result<Vec<u8>, String> { let mut ips_str = String::new();
if !patch.starts_with(b"PATCH") { let mut bin_str = String::new();
return Err("the selected file is not a valid IPS patch".to_string()); 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}"),
} }
}
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;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
use gtk4::prelude::*;
use gtk4::{
AboutDialog,
glib::*,
Button,
Popover,
License,
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
@@ -1,247 +0,0 @@
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)");
}
}
+116
View File
@@ -0,0 +1,116 @@
use std::rc::Rc;
use futures::executor::block_on;
use gio::{Menu, MenuItem, SimpleAction, SimpleActionGroup};
use gtk4::builders::PopoverMenuBuilder;
use gtk4::{prelude::*, PopoverMenu, AboutDialog, License};
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)]
pub struct CustomTitleBar {
pub window: Window,
pub titlebar: HeaderBar,
pub run_button: Button,
pub menu_button: MenuButton,
pub menu: Menu,
pub menu_actions: SimpleActionGroup,
}
impl CustomTitleBar {
pub fn new() -> CustomTitleBar {
// loading PopoverMenu logic goes here
CustomTitleBar {
window: Window::new(),
titlebar: HeaderBar::new(),
run_button: Button::new(),
menu_button: MenuButton::new(),
menu: Menu::new(),
menu_actions: SimpleActionGroup::new(),
}
}
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));
self.menu.append(Some("About"), Some("about_action"));
self.menu.append(Some("Keyboard Shortcuts"), None);
self.menu.append(Some("Quit"), None);
let popover_menu = Some(PopoverMenu::builder().menu_model(&self.menu).build());
self.menu_button.set_popover(Some(&popover_menu.unwrap()));
}
pub fn set_actions(&self) {
// about action
let about_action = SimpleAction::new("about_action", None);
about_action.set_enabled(true);
about_action.connect_activate(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();
}));
// add actions to the menu
self.menu_actions.add_action(&about_action);
//println!("{:?}", self.menu_actions.is_action_enabled("About"));
}
pub fn click_run_button(&self) {
self.run_button.connect_clicked(move |button| {
button.set_label("Avery is awesome!");
});
}
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?
}
}