add: refactored everything
This commit is contained in:
Generated
+1
-99
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -8,8 +8,3 @@ edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
gtk4 = "0.4.7"
|
||||
glib = "*"
|
||||
gio = "*"
|
||||
gdk = "*"
|
||||
futures = "0.3"
|
||||
byteorder = "*"
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
>k4::gdk::Display::default().expect("Could not connect to a display"),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
|
||||
let builder = Builder::new();
|
||||
builder.add_from_resource("/com/wyattjmiller/snespatcher/appmenu.xml").expect("Error initializing app menu!");
|
||||
//println!("{:?}", builder.list_properties());
|
||||
|
||||
let app_window: Rc<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}")
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
enum PatchType {
|
||||
Ips,
|
||||
Bps,
|
||||
}
|
||||
+350
-19
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user