├── .gitignore ├── test_files ├── empty-file ├── 0-255.bin ├── testimg.bmp ├── testimg.png ├── utf16_le.bin ├── plaintext.txt ├── primitives.bin └── utf16_be.bin ├── src ├── gui │ ├── top_menu │ │ ├── perspective.rs │ │ ├── help.rs │ │ ├── cursor.rs │ │ ├── scripting.rs │ │ ├── analysis.rs │ │ ├── plugins.rs │ │ ├── view.rs │ │ ├── meta.rs │ │ ├── file.rs │ │ └── edit.rs │ ├── dialogs.rs │ ├── ops.rs │ ├── egui_ui_ext.rs │ ├── windows │ │ ├── lua_watch.rs │ │ ├── debug.rs │ │ ├── lua_help.rs │ │ ├── vars.rs │ │ ├── script_manager.rs │ │ ├── meta_diff.rs │ │ ├── lua_console.rs │ │ ├── about.rs │ │ ├── find_memory_pointers.rs │ │ ├── zero_partition.rs │ │ └── perspectives.rs │ ├── dialogs │ │ ├── auto_save_reload.rs │ │ ├── pattern_fill.rs │ │ ├── lua_color.rs │ │ ├── x86_asm.rs │ │ ├── jump.rs │ │ ├── lua_fill.rs │ │ └── truncate.rs │ ├── command.rs │ ├── selection_menu.rs │ ├── windows.rs │ ├── root_ctx_menu.rs │ └── message_dialog.rs ├── backend.rs ├── str_ext.rs ├── result_ext.rs ├── app │ ├── debug.rs │ ├── interact_mode.rs │ ├── presentation.rs │ ├── backend_command.rs │ ├── edit_state.rs │ └── command.rs ├── util.rs ├── slice_ext.rs ├── meta_state.rs ├── timer.rs ├── input.rs ├── meta │ ├── region.rs │ └── perspective.rs ├── shell.rs ├── damage_region.rs ├── dec_conv.rs ├── parse_radix.rs ├── backend │ └── sfml.rs ├── color.rs ├── hex_conv.rs ├── session_prefs.rs ├── find_util.rs ├── edit_buffer.rs ├── source.rs ├── windows.rs ├── plugin.rs ├── hex_ui.rs ├── config.rs ├── args.rs ├── struct_meta_item.rs ├── data.rs └── layout.rs ├── rustfmt.toml ├── DejaVuSansMono.ttf ├── lua ├── fill.lua └── color.lua ├── hexerator-plugin-api ├── Cargo.toml └── src │ └── lib.rs ├── rust-toolchain.toml ├── plugins └── hello-world │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ └── lib.rs ├── .github └── workflows │ ├── windows.yml │ └── linux.yml ├── README.md ├── LICENSE-MIT ├── scripts └── gen-prim-test-file.rs └── Cargo.toml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /test_files/empty-file: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gui/top_menu/perspective.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/backend.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "backend-sfml")] 2 | mod sfml; 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "One" 2 | group_imports = "One" 3 | chain_width = 80 -------------------------------------------------------------------------------- /DejaVuSansMono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crumblingstatue/hexerator/HEAD/DejaVuSansMono.ttf -------------------------------------------------------------------------------- /test_files/0-255.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crumblingstatue/hexerator/HEAD/test_files/0-255.bin -------------------------------------------------------------------------------- /test_files/testimg.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crumblingstatue/hexerator/HEAD/test_files/testimg.bmp -------------------------------------------------------------------------------- /test_files/testimg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crumblingstatue/hexerator/HEAD/test_files/testimg.png -------------------------------------------------------------------------------- /test_files/utf16_le.bin: -------------------------------------------------------------------------------- 1 | Hello world, this is a UTF-16-LE document -------------------------------------------------------------------------------- /test_files/plaintext.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crumblingstatue/hexerator/HEAD/test_files/plaintext.txt -------------------------------------------------------------------------------- /test_files/primitives.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crumblingstatue/hexerator/HEAD/test_files/primitives.bin -------------------------------------------------------------------------------- /lua/fill.lua: -------------------------------------------------------------------------------- 1 | -- Return a byte based on offset `off` and the current byte value `b` 2 | function(off, b) 3 | return off % 256 4 | end -------------------------------------------------------------------------------- /hexerator-plugin-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hexerator-plugin-api" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] -------------------------------------------------------------------------------- /lua/color.lua: -------------------------------------------------------------------------------- 1 | return function(b) 2 | local r = b 3 | local g = b 4 | local b = b 5 | return {r % 256, g % 256, b % 256} 6 | end -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | # Lock to a specific nightly before release 4 | #channel = "nightly-2025-03-22" 5 | -------------------------------------------------------------------------------- /test_files/utf16_be.bin: -------------------------------------------------------------------------------- 1 | Hello world, this is a UTF-16 big endian document. That's right. -------------------------------------------------------------------------------- /plugins/hello-world/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "hello-world" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /src/str_ext.rs: -------------------------------------------------------------------------------- 1 | pub trait StrExt { 2 | fn is_empty_or_ws_only(&self) -> bool; 3 | } 4 | 5 | impl StrExt for str { 6 | fn is_empty_or_ws_only(&self) -> bool { 7 | self.trim().is_empty() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /plugins/hello-world/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hello-world" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | hexerator-plugin-api = { path = "../../hexerator-plugin-api" } -------------------------------------------------------------------------------- /src/result_ext.rs: -------------------------------------------------------------------------------- 1 | pub trait AnyhowConv 2 | where 3 | anyhow::Error: From, 4 | { 5 | fn how(self) -> anyhow::Result; 6 | } 7 | 8 | impl AnyhowConv for Result 9 | where 10 | anyhow::Error: From, 11 | { 12 | fn how(self) -> anyhow::Result { 13 | self.map_err(anyhow::Error::from) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/debug.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports)] 2 | use { 3 | super::App, 4 | gamedebug_core::{imm, imm_dbg}, 5 | }; 6 | 7 | impl App { 8 | /// Central place to put some immediate state debugging (using gamedebug_core) 9 | pub(crate) fn imm_debug_fun(&self) { 10 | // Put immediate debugging code here (F12 to open debug console) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/gui/dialogs.rs: -------------------------------------------------------------------------------- 1 | mod auto_save_reload; 2 | mod jump; 3 | mod lua_color; 4 | mod lua_fill; 5 | pub mod pattern_fill; 6 | mod truncate; 7 | mod x86_asm; 8 | 9 | pub use { 10 | auto_save_reload::AutoSaveReloadDialog, jump::JumpDialog, lua_color::LuaColorDialog, 11 | lua_fill::LuaFillDialog, pattern_fill::PatternFillDialog, truncate::TruncateDialog, 12 | x86_asm::X86AsmDialog, 13 | }; 14 | -------------------------------------------------------------------------------- /src/app/interact_mode.rs: -------------------------------------------------------------------------------- 1 | /// User interaction mode 2 | /// 3 | /// There are 2 modes: View and Edit 4 | #[derive(PartialEq, Eq, Debug)] 5 | pub enum InteractMode { 6 | /// Mode optimized for viewing the contents 7 | /// 8 | /// For example arrow keys scroll the content 9 | View, 10 | /// Mode optimized for editing the contents 11 | /// 12 | /// For example arrow keys move the cursor 13 | Edit, 14 | } 15 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | #[expect( 2 | clippy::cast_precision_loss, 3 | reason = "This is just an approximation of data size" 4 | )] 5 | pub fn human_size(size: usize) -> String { 6 | human_bytes::human_bytes(size as f64) 7 | } 8 | 9 | #[expect( 10 | clippy::cast_precision_loss, 11 | reason = "This is just an approximation of data size" 12 | )] 13 | pub fn human_size_u64(size: u64) -> String { 14 | human_bytes::human_bytes(size as f64) 15 | } 16 | -------------------------------------------------------------------------------- /src/gui/ops.rs: -------------------------------------------------------------------------------- 1 | //! Various common operations that are triggered by gui interactions 2 | 3 | use crate::{gui::windows::RegionsWindow, meta::region::Region, meta_state::MetaState}; 4 | 5 | pub fn add_region_from_selection( 6 | selection: Region, 7 | app_meta_state: &mut MetaState, 8 | gui_regions_window: &mut RegionsWindow, 9 | ) { 10 | let key = app_meta_state.meta.add_region_from_selection(selection); 11 | gui_regions_window.open.set(true); 12 | gui_regions_window.selected_key = Some(key); 13 | gui_regions_window.activate_rename = true; 14 | } 15 | -------------------------------------------------------------------------------- /src/slice_ext.rs: -------------------------------------------------------------------------------- 1 | pub trait SliceExt { 2 | fn pattern_fill(&mut self, pattern: &Self); 3 | } 4 | 5 | impl SliceExt for [T] { 6 | fn pattern_fill(&mut self, pattern: &Self) { 7 | for (src, dst) in pattern.iter().cycle().zip(self.iter_mut()) { 8 | *dst = *src; 9 | } 10 | } 11 | } 12 | 13 | #[test] 14 | fn test_pattern_fill() { 15 | let mut buf = [0u8; 10]; 16 | buf.pattern_fill(b"foo"); 17 | assert_eq!(&buf, b"foofoofoof"); 18 | buf.pattern_fill(b"Hello, World!"); 19 | assert_eq!(&buf, b"Hello, Wor"); 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: Windows 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | tags: 7 | - v* 8 | pull_request: 9 | branches: [ "main" ] 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: windows-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Run tests 22 | run: cargo test --verbose 23 | - name: Do a release build 24 | run: cargo build --release --verbose 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: hexerator-win64-build 28 | path: target/release/hexerator.exe -------------------------------------------------------------------------------- /src/meta_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::meta::Meta, 3 | std::{cell::Cell, path::PathBuf, time::Instant}, 4 | }; 5 | 6 | pub struct MetaState { 7 | pub last_meta_backup: Cell, 8 | pub current_meta_path: PathBuf, 9 | /// Clean copy of the metadata from last load/save 10 | pub clean_meta: Meta, 11 | pub meta: Meta, 12 | } 13 | 14 | impl Default for MetaState { 15 | fn default() -> Self { 16 | Self { 17 | meta: Meta::default(), 18 | clean_meta: Meta::default(), 19 | last_meta_backup: Cell::new(Instant::now()), 20 | current_meta_path: PathBuf::new(), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/timer.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | #[derive(Debug)] 4 | pub struct Timer { 5 | init_point: Instant, 6 | duration: Duration, 7 | } 8 | 9 | impl Timer { 10 | pub fn set(duration: Duration) -> Self { 11 | Self { 12 | init_point: Instant::now(), 13 | duration, 14 | } 15 | } 16 | pub fn overtime(&self) -> Option { 17 | let elapsed = self.init_point.elapsed(); 18 | if elapsed > self.duration { 19 | None 20 | } else { 21 | Some(elapsed) 22 | } 23 | } 24 | } 25 | 26 | impl Default for Timer { 27 | fn default() -> Self { 28 | Self::set(Duration::ZERO) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hexerator 2 | Versatile GUI hex editor focused on binary file exploration and aiding pattern recognition. Written in Rust. 3 | 4 | Check out [the Hexerator book](https://crumblingstatue.github.io/hexerator-book/0.4.0) for a detailed list of features, and more! 5 | 6 | ## Note for contributors: 7 | Hexerator only supports latest nightly Rust. 8 | You need an up-to-date nightly to build Hexerator. 9 | 10 | Hexerator doesn't shy away from experimenting with unstable Rust features. 11 | Contributors can use any nightly feature they wish. 12 | 13 | Contributors however are free to rewrite code to use stable features if it doesn't result in: 14 | 15 | - A loss of features 16 | - Reduced performance 17 | - Significantly worse maintainability 18 | -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | name: Linux 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Install deps 20 | run: | 21 | sudo apt-get update 22 | sudo apt-get install libpthread-stubs0-dev libgl1-mesa-dev libx11-dev libx11-xcb-dev libxcb-image0-dev libxrandr-dev libxcb-randr0-dev libudev-dev libfreetype6-dev libglew-dev libjpeg8-dev libgpgme11-dev libjpeg62 libxcursor-dev cmake libclang-dev clang 23 | - name: Build 24 | run: cargo build --verbose 25 | - name: Run tests 26 | run: cargo test --verbose 27 | -------------------------------------------------------------------------------- /src/gui/egui_ui_ext.rs: -------------------------------------------------------------------------------- 1 | pub trait EguiResponseExt { 2 | fn on_hover_text_deferred(self, text_fun: F) -> Self 3 | where 4 | F: FnOnce() -> R, 5 | R: Into; 6 | } 7 | 8 | impl EguiResponseExt for egui::Response { 9 | fn on_hover_text_deferred(self, text_fun: F) -> Self 10 | where 11 | F: FnOnce() -> R, 12 | R: Into, 13 | { 14 | // Yoinked from egui source 15 | self.on_hover_ui(|ui| { 16 | // Prevent `Area` auto-sizing from shrinking tooltips with dynamic content. 17 | // See https://github.com/emilk/egui/issues/5167 18 | ui.set_max_width(ui.spacing().tooltip_width); 19 | 20 | ui.add(egui::Label::new(text_fun())); 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use { 2 | egui_sf2g::sf2g::window::{Event, Key}, 3 | std::collections::HashSet, 4 | }; 5 | 6 | #[derive(Default, Debug)] 7 | pub struct Input { 8 | key_down: HashSet, 9 | } 10 | 11 | impl Input { 12 | pub fn update_from_event(&mut self, event: &Event) { 13 | match event { 14 | Event::KeyPressed { code, .. } => { 15 | self.key_down.insert(*code); 16 | } 17 | Event::KeyReleased { code, .. } => { 18 | self.key_down.remove(code); 19 | } 20 | _ => {} 21 | } 22 | } 23 | pub fn key_down(&self, key: Key) -> bool { 24 | self.key_down.contains(&key) 25 | } 26 | 27 | pub(crate) fn clear(&mut self) { 28 | self.key_down.clear(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/meta/region.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// An inclusive region spanning `begin` to `end` 4 | #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] 5 | pub struct Region { 6 | pub begin: usize, 7 | pub end: usize, 8 | } 9 | 10 | impl Region { 11 | pub fn len(&self) -> usize { 12 | // Inclusive, so add 1 to end 13 | (self.end + 1).saturating_sub(self.begin) 14 | } 15 | 16 | pub(crate) fn contains(&self, idx: usize) -> bool { 17 | (self.begin..=self.end).contains(&idx) 18 | } 19 | 20 | pub(crate) fn contains_region(&self, reg: &Self) -> bool { 21 | self.begin <= reg.begin && self.end >= reg.end 22 | } 23 | pub fn to_range(self) -> std::ops::RangeInclusive { 24 | self.begin..=self.end 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/presentation.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::{RgbaColor, rgba}, 4 | value_color::ColorMethod, 5 | }, 6 | serde::{Deserialize, Serialize}, 7 | }; 8 | 9 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] 10 | pub struct Presentation { 11 | pub color_method: ColorMethod, 12 | pub invert_color: bool, 13 | pub sel_color: RgbaColor, 14 | pub cursor_color: RgbaColor, 15 | pub cursor_active_color: RgbaColor, 16 | } 17 | 18 | impl Default for Presentation { 19 | fn default() -> Self { 20 | Self { 21 | color_method: ColorMethod::Default, 22 | invert_color: false, 23 | sel_color: rgba(75, 75, 75, 255), 24 | cursor_color: rgba(160, 160, 160, 255), 25 | cursor_active_color: rgba(255, 255, 255, 255), 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/shell.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | gui::message_dialog::{Icon, MessageDialog}, 5 | }, 6 | std::backtrace::Backtrace, 7 | }; 8 | 9 | pub fn open_previous(app: &App, load: &mut Option) { 10 | if let Some(src_args) = app.cfg.recent.iter().nth(1) { 11 | *load = Some(src_args.clone()); 12 | } 13 | } 14 | 15 | pub fn msg_if_fail( 16 | result: Result, 17 | prefix: &str, 18 | msg: &mut MessageDialog, 19 | ) -> Option { 20 | if let Err(e) = result { 21 | msg_fail(&e, prefix, msg); 22 | Some(e) 23 | } else { 24 | None 25 | } 26 | } 27 | 28 | pub fn msg_fail(e: &E, prefix: &str, msg: &mut MessageDialog) { 29 | msg.open(Icon::Error, "Error", format!("{prefix}: {e:#}")); 30 | msg.set_backtrace_for_top(Backtrace::force_capture()); 31 | } 32 | -------------------------------------------------------------------------------- /src/damage_region.rs: -------------------------------------------------------------------------------- 1 | pub enum DamageRegion { 2 | Single(usize), 3 | Range(std::ops::Range), 4 | RangeInclusive(std::ops::RangeInclusive), 5 | } 6 | 7 | impl DamageRegion { 8 | pub(crate) fn begin(&self) -> usize { 9 | match self { 10 | Self::Single(offset) => *offset, 11 | Self::Range(range) => range.start, 12 | Self::RangeInclusive(range) => *range.start(), 13 | } 14 | } 15 | 16 | pub(crate) fn end(&self) -> usize { 17 | match self { 18 | Self::Single(offset) => *offset, 19 | Self::Range(range) => range.end - 1, 20 | Self::RangeInclusive(range) => *range.end(), 21 | } 22 | } 23 | } 24 | 25 | impl From> for DamageRegion { 26 | fn from(range: std::ops::RangeInclusive) -> Self { 27 | Self::RangeInclusive(range) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/dec_conv.rs: -------------------------------------------------------------------------------- 1 | fn byte_10_digits(byte: u8) -> [u8; 3] { 2 | [byte / 100, (byte % 100) / 10, byte % 10] 3 | } 4 | 5 | #[test] 6 | fn test_byte_10_digits() { 7 | assert_eq!(byte_10_digits(255), [2, 5, 5]); 8 | } 9 | 10 | pub fn byte_to_dec_digits(byte: u8) -> [u8; 3] { 11 | const TABLE: &[u8; 10] = b"0123456789"; 12 | 13 | let [a, b, c] = byte_10_digits(byte); 14 | [TABLE[a as usize], TABLE[b as usize], TABLE[c as usize]] 15 | } 16 | 17 | #[test] 18 | fn test_byte_to_dec_digits() { 19 | let pairs = [ 20 | (255, b"255"), 21 | (0, b"000"), 22 | (1, b"001"), 23 | (15, b"015"), 24 | (16, b"016"), 25 | (154, b"154"), 26 | (167, b"167"), 27 | (6, b"006"), 28 | (64, b"064"), 29 | (127, b"127"), 30 | (128, b"128"), 31 | (129, b"129"), 32 | ]; 33 | for (byte, hex) in pairs { 34 | assert_eq!(byte_to_dec_digits(byte), *hex); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/gui/top_menu/help.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{gui::Gui, shell::msg_if_fail}, 3 | constcat::concat, 4 | egui::{Button, Ui}, 5 | egui_phosphor::regular as ic, 6 | gamedebug_core::{IMMEDIATE, PERSISTENT}, 7 | }; 8 | 9 | const L_HEXERATOR_BOOK: &str = concat!(ic::BOOK_OPEN_TEXT, " Hexerator book"); 10 | const L_DEBUG_PANEL: &str = concat!(ic::BUG, " Debug panel..."); 11 | const L_ABOUT: &str = concat!(ic::QUESTION, " About Hexerator..."); 12 | 13 | pub fn ui(ui: &mut Ui, gui: &mut Gui) { 14 | if ui.button(L_HEXERATOR_BOOK).clicked() { 15 | msg_if_fail( 16 | open::that(crate::gui::BOOK_URL), 17 | "Failed to open help", 18 | &mut gui.msg_dialog, 19 | ); 20 | } 21 | if ui.add(Button::new(L_DEBUG_PANEL).shortcut_text("F12")).clicked() { 22 | IMMEDIATE.toggle(); 23 | PERSISTENT.toggle(); 24 | } 25 | ui.separator(); 26 | if ui.button(L_ABOUT).clicked() { 27 | gui.win.about.open.toggle(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/parse_radix.rs: -------------------------------------------------------------------------------- 1 | use num_traits::Num; 2 | 3 | pub fn parse_guess_radix(input: &str) -> Result::FromStrRadixErr> { 4 | if let Some(stripped) = input.strip_prefix("0x") { 5 | T::from_str_radix(stripped, 16) 6 | } else if input.contains(['a', 'b', 'c', 'd', 'e', 'f']) { 7 | T::from_str_radix(input, 16) 8 | } else { 9 | T::from_str_radix(input, 10) 10 | } 11 | } 12 | 13 | /// Relativity of an offset 14 | pub enum Relativity { 15 | Absolute, 16 | RelAdd, 17 | RelSub, 18 | } 19 | 20 | pub fn parse_offset_maybe_relative( 21 | input: &str, 22 | ) -> Result<(usize, Relativity), ::FromStrRadixErr> { 23 | Ok(if let Some(stripped) = input.strip_prefix('-') { 24 | (parse_guess_radix(stripped.trim_end())?, Relativity::RelSub) 25 | } else if let Some(stripped) = input.strip_prefix('+') { 26 | (parse_guess_radix(stripped.trim_end())?, Relativity::RelAdd) 27 | } else { 28 | (parse_guess_radix(input.trim_end())?, Relativity::Absolute) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/backend/sfml.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | color::{RgbColor, RgbaColor}, 4 | view::{ViewportScalar, ViewportVec}, 5 | }, 6 | egui_sf2g::sf2g::graphics::Color, 7 | }; 8 | 9 | impl From for RgbaColor { 10 | fn from(Color { r, g, b, a }: Color) -> Self { 11 | Self { r, g, b, a } 12 | } 13 | } 14 | 15 | impl From for Color { 16 | fn from(RgbaColor { r, g, b, a }: RgbaColor) -> Self { 17 | Self { r, g, b, a } 18 | } 19 | } 20 | 21 | impl From for Color { 22 | fn from(src: RgbColor) -> Self { 23 | Self { 24 | r: src.r, 25 | g: src.g, 26 | b: src.b, 27 | a: 255, 28 | } 29 | } 30 | } 31 | 32 | impl TryFrom> for ViewportVec { 33 | type Error = >::Error; 34 | 35 | fn try_from(sf_vec: sf2g::system::Vector2) -> Result { 36 | Ok(Self { 37 | x: sf_vec.x.try_into()?, 38 | y: sf_vec.y.try_into()?, 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 crumblingstatue and Hexerator contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/gen-prim-test-file.rs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S cargo +nightly -Zscript 2 | 3 | use std::{fs::File, io::Write}; 4 | 5 | fn main() { 6 | let mut f = File::create("test_files/primitives.bin").unwrap(); 7 | macro_rules! prim { 8 | ($t:ident, $val:literal) => { 9 | let v: $t = $val; 10 | let mut buf = std::io::Cursor::new([0u8; 48]); 11 | // Write desc 12 | write!(&mut buf, "{} = {}", stringify!($t), v).unwrap(); 13 | f.write_all(buf.get_ref()).unwrap(); 14 | // Write byte repr 15 | // le 16 | buf.get_mut().fill(0); 17 | buf.get_mut()[..std::mem::size_of::<$t>()].copy_from_slice(&v.to_le_bytes()); 18 | f.write_all(buf.get_ref()).unwrap(); 19 | // be 20 | buf.get_mut().fill(0); 21 | buf.get_mut()[..std::mem::size_of::<$t>()].copy_from_slice(&v.to_be_bytes()); 22 | f.write_all(buf.get_ref()).unwrap(); 23 | }; 24 | } 25 | prim!(u8, 42); 26 | prim!(i8, 42); 27 | prim!(u16, 4242); 28 | prim!(i16, 4242); 29 | prim!(u32, 424242); 30 | prim!(i32, 424242); 31 | prim!(u64, 424242424242); 32 | prim!(i64, 424242424242); 33 | prim!(u128, 424242424242424242424242); 34 | prim!(i128, 424242424242424242424242); 35 | prim!(f32, 42.4242); 36 | prim!(f64, 4242.42424242); 37 | } 38 | -------------------------------------------------------------------------------- /src/gui/top_menu/cursor.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | gui::{Gui, dialogs::JumpDialog}, 5 | }, 6 | constcat::concat, 7 | egui::Button, 8 | egui_phosphor::regular as ic, 9 | }; 10 | 11 | const L_RESET: &str = concat!(ic::ARROW_U_UP_LEFT, " Reset"); 12 | const L_JUMP: &str = concat!(ic::SHARE_FAT, " Jump..."); 13 | const L_FLASH_CURSOR: &str = concat!(ic::LIGHTBULB, " Flash cursor"); 14 | const L_CENTER_VIEW_ON_CURSOR: &str = concat!(ic::CROSSHAIR, " Center view on cursor"); 15 | 16 | pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App) { 17 | let re = ui.button(L_RESET).on_hover_text( 18 | "Set to initial position.\n\ 19 | This will be --jump argument, if one was provided, 0 otherwise", 20 | ); 21 | if re.clicked() { 22 | app.set_cursor_init(); 23 | } 24 | if ui.add(Button::new(L_JUMP).shortcut_text("Ctrl+J")).clicked() { 25 | Gui::add_dialog(&mut gui.dialogs, JumpDialog::default()); 26 | } 27 | if ui.button(L_FLASH_CURSOR).clicked() { 28 | app.preferences.hide_cursor = false; 29 | app.hex_ui.flash_cursor(); 30 | } 31 | if ui.button(L_CENTER_VIEW_ON_CURSOR).clicked() { 32 | app.preferences.hide_cursor = false; 33 | app.center_view_on_offset(app.edit_state.cursor); 34 | app.hex_ui.flash_cursor(); 35 | } 36 | ui.checkbox(&mut app.preferences.hide_cursor, "Hide cursor"); 37 | } 38 | -------------------------------------------------------------------------------- /src/gui/windows/lua_watch.rs: -------------------------------------------------------------------------------- 1 | use {super::WinCtx, crate::scripting::exec_lua}; 2 | 3 | pub struct LuaWatchWindow { 4 | pub name: String, 5 | expr: String, 6 | watch: bool, 7 | } 8 | 9 | impl Default for LuaWatchWindow { 10 | fn default() -> Self { 11 | Self { 12 | name: "New watch window".into(), 13 | expr: String::new(), 14 | watch: false, 15 | } 16 | } 17 | } 18 | 19 | impl super::Window for LuaWatchWindow { 20 | fn ui( 21 | &mut self, 22 | WinCtx { 23 | ui, 24 | gui, 25 | app, 26 | lua, 27 | font_size, 28 | line_spacing, 29 | .. 30 | }: WinCtx, 31 | ) { 32 | ui.text_edit_singleline(&mut self.name); 33 | ui.text_edit_singleline(&mut self.expr); 34 | ui.checkbox(&mut self.watch, "watch"); 35 | if self.watch { 36 | match exec_lua(lua, &self.expr, app, gui, "", None, font_size, line_spacing) { 37 | Ok(ret) => { 38 | if let Some(s) = ret { 39 | ui.label(s); 40 | } else { 41 | ui.label("No output"); 42 | } 43 | } 44 | Err(e) => { 45 | ui.label(e.to_string()); 46 | } 47 | } 48 | } 49 | } 50 | 51 | fn title(&self) -> &str { 52 | &self.name 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/gui/top_menu/scripting.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Gui, shell::msg_if_fail}, 3 | mlua::Lua, 4 | }; 5 | 6 | pub fn ui( 7 | ui: &mut egui::Ui, 8 | gui: &mut Gui, 9 | app: &mut App, 10 | lua: &Lua, 11 | font_size: u16, 12 | line_spacing: u16, 13 | ) { 14 | if ui.button("🖹 Lua editor").clicked() { 15 | gui.win.lua_editor.open.toggle(); 16 | } 17 | if ui.button("📃 Script manager").clicked() { 18 | gui.win.script_manager.open.toggle(); 19 | } 20 | if ui.button("🖳 Quick eval window").clicked() { 21 | gui.win.lua_console.open.toggle(); 22 | } 23 | if ui.button("👁 New watch window").clicked() { 24 | gui.win.add_lua_watch_window(); 25 | } 26 | if ui.button("? Hexerator Lua API").clicked() { 27 | gui.win.lua_help.open.toggle(); 28 | } 29 | ui.separator(); 30 | let mut scripts = std::mem::take(&mut app.meta_state.meta.scripts); 31 | for (key, script) in scripts.iter() { 32 | if ui.button(&script.name).clicked() { 33 | let result = crate::scripting::exec_lua( 34 | lua, 35 | &script.content, 36 | app, 37 | gui, 38 | "", 39 | Some(key), 40 | font_size, 41 | line_spacing, 42 | ); 43 | msg_if_fail(result, "Failed to execute script", &mut gui.msg_dialog); 44 | } 45 | } 46 | std::mem::swap(&mut app.meta_state.meta.scripts, &mut scripts); 47 | } 48 | -------------------------------------------------------------------------------- /src/gui/windows/debug.rs: -------------------------------------------------------------------------------- 1 | use { 2 | egui::Ui, 3 | gamedebug_core::{IMMEDIATE, PERSISTENT}, 4 | }; 5 | 6 | pub fn ui(ui: &mut Ui) { 7 | ui.horizontal(|ui| { 8 | if ui.button("Clear persistent").clicked() { 9 | PERSISTENT.clear(); 10 | } 11 | }); 12 | ui.separator(); 13 | egui::ScrollArea::vertical() 14 | .max_height(500.) 15 | .auto_shrink([false, true]) 16 | .show(ui, |ui| { 17 | IMMEDIATE.for_each(|msg| { 18 | ui.label(msg); 19 | }); 20 | }); 21 | IMMEDIATE.clear(); 22 | ui.separator(); 23 | egui::ScrollArea::vertical() 24 | .id_salt("per_scroll") 25 | .max_height(500.0) 26 | .show(ui, |ui| { 27 | egui::Grid::new("per_grid").striped(true).show(ui, |ui| { 28 | PERSISTENT.for_each(|msg| { 29 | ui.label( 30 | egui::RichText::new(msg.frame.to_string()).color(egui::Color32::DARK_GRAY), 31 | ); 32 | if let Some(src_loc) = &msg.src_loc { 33 | let txt = format!("{}:{}:{}", src_loc.file, src_loc.line, src_loc.column); 34 | if ui.link(&txt).on_hover_text("Click to copy to clipboard").clicked() { 35 | ui.ctx().copy_text(txt); 36 | } 37 | } 38 | ui.label(&msg.info); 39 | ui.end_row(); 40 | }); 41 | }); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/color.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] 4 | pub struct RgbaColor { 5 | pub r: u8, 6 | pub g: u8, 7 | pub b: u8, 8 | pub a: u8, 9 | } 10 | impl RgbaColor { 11 | pub(crate) fn with_as_egui_mut(&mut self, f: impl FnOnce(&mut egui::Color32)) { 12 | let mut ec = self.to_egui(); 13 | f(&mut ec); 14 | *self = Self::from_egui(ec); 15 | } 16 | fn from_egui(c: egui::Color32) -> Self { 17 | Self { 18 | r: c.r(), 19 | g: c.g(), 20 | b: c.b(), 21 | a: c.a(), 22 | } 23 | } 24 | fn to_egui(self) -> egui::Color32 { 25 | egui::Color32::from_rgba_premultiplied(self.r, self.g, self.b, self.a) 26 | } 27 | } 28 | 29 | pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> RgbaColor { 30 | RgbaColor { r, g, b, a } 31 | } 32 | 33 | #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] 34 | pub struct RgbColor { 35 | pub r: u8, 36 | pub g: u8, 37 | pub b: u8, 38 | } 39 | 40 | impl RgbColor { 41 | pub const WHITE: Self = rgb(255, 255, 255); 42 | 43 | pub fn invert(&self) -> Self { 44 | rgb(!self.r, !self.g, !self.b) 45 | } 46 | 47 | pub(crate) fn cap_brightness(&self, limit: u8) -> Self { 48 | Self { 49 | r: self.r.min(limit), 50 | g: self.g.min(limit), 51 | b: self.b.min(limit), 52 | } 53 | } 54 | } 55 | 56 | pub const fn rgb(r: u8, g: u8, b: u8) -> RgbColor { 57 | RgbColor { r, g, b } 58 | } 59 | -------------------------------------------------------------------------------- /src/gui/windows/lua_help.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::scripting::*, 4 | egui::Color32, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct LuaHelpWindow { 9 | pub open: WindowOpen, 10 | pub filter: String, 11 | } 12 | 13 | impl super::Window for LuaHelpWindow { 14 | fn ui(&mut self, WinCtx { ui, .. }: WinCtx) { 15 | ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text("🔍 Filter")); 16 | egui::ScrollArea::vertical().max_height(500.0).show(ui, |ui| { 17 | macro_rules! add_help { 18 | ($t:ty) => { 19 | 'block: { 20 | let filter_lower = &self.filter.to_ascii_lowercase(); 21 | if !(<$t>::NAME.to_ascii_lowercase().contains(filter_lower) 22 | || <$t>::HELP.to_ascii_lowercase().contains(filter_lower)) 23 | { 24 | break 'block; 25 | } 26 | ui.horizontal(|ui| { 27 | ui.style_mut().spacing.item_spacing = egui::vec2(0., 0.); 28 | ui.label("hx:"); 29 | ui.label( 30 | egui::RichText::new(<$t>::API_SIG).color(Color32::WHITE).strong(), 31 | ); 32 | }); 33 | ui.indent("doc_indent", |ui| { 34 | ui.label(<$t>::HELP); 35 | }); 36 | } 37 | }; 38 | } 39 | for_each_method!(add_help); 40 | }); 41 | } 42 | 43 | fn title(&self) -> &str { 44 | "Lua help" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /hexerator-plugin-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub trait Plugin { 2 | fn name(&self) -> &str; 3 | fn desc(&self) -> &str; 4 | fn methods(&self) -> Vec; 5 | fn on_method_called( 6 | &mut self, 7 | name: &str, 8 | params: &[Option], 9 | hx: &mut dyn HexeratorHandle, 10 | ) -> MethodResult; 11 | } 12 | 13 | pub type MethodResult = Result, String>; 14 | 15 | pub struct PluginMethod { 16 | pub method_name: &'static str, 17 | pub human_name: Option<&'static str>, 18 | pub desc: &'static str, 19 | pub params: &'static [MethodParam], 20 | } 21 | 22 | pub struct MethodParam { 23 | pub name: &'static str, 24 | pub ty: ValueTy, 25 | } 26 | 27 | pub enum ValueTy { 28 | U64, 29 | String, 30 | } 31 | 32 | pub enum Value { 33 | U64(u64), 34 | F64(f64), 35 | String(String), 36 | } 37 | 38 | impl ValueTy { 39 | pub fn label(&self) -> &'static str { 40 | match self { 41 | ValueTy::U64 => "u64", 42 | ValueTy::String => "string", 43 | } 44 | } 45 | } 46 | 47 | pub trait HexeratorHandle { 48 | fn selection_range(&self) -> Option<[usize; 2]>; 49 | fn get_data(&self, start: usize, end: usize) -> Option<&[u8]>; 50 | fn get_data_mut(&mut self, start: usize, end: usize) -> Option<&mut [u8]>; 51 | fn debug_log(&self, msg: &str); 52 | fn perspective(&self, name: &str) -> Option; 53 | fn perspective_rows(&self, ph: &PerspectiveHandle) -> Vec<&[u8]>; 54 | } 55 | 56 | pub struct PerspectiveHandle { 57 | pub key_data: u64, 58 | } 59 | 60 | impl PerspectiveHandle { 61 | pub fn rows<'hx>(&self, hx: &'hx dyn HexeratorHandle) -> Vec<&'hx [u8]> { 62 | hx.perspective_rows(self) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/hex_conv.rs: -------------------------------------------------------------------------------- 1 | fn byte_16_digits(byte: u8) -> [u8; 2] { 2 | [byte / 16, byte % 16] 3 | } 4 | 5 | #[test] 6 | fn test_byte_16_digits() { 7 | assert_eq!(byte_16_digits(255), [15, 15]); 8 | } 9 | 10 | pub fn byte_to_hex_digits(byte: u8) -> [u8; 2] { 11 | const TABLE: &[u8; 16] = b"0123456789ABCDEF"; 12 | 13 | let [l, r] = byte_16_digits(byte); 14 | [TABLE[l as usize], TABLE[r as usize]] 15 | } 16 | 17 | #[test] 18 | fn test_byte_to_hex_digits() { 19 | let pairs = [ 20 | (255, b"FF"), 21 | (0, b"00"), 22 | (15, b"0F"), 23 | (16, b"10"), 24 | (154, b"9A"), 25 | (167, b"A7"), 26 | (6, b"06"), 27 | (64, b"40"), 28 | ]; 29 | for (byte, hex) in pairs { 30 | assert_eq!(byte_to_hex_digits(byte), *hex); 31 | } 32 | } 33 | 34 | fn digit_to_byte(digit: u8) -> Option { 35 | Some(match digit { 36 | b'0' => 0, 37 | b'1' => 1, 38 | b'2' => 2, 39 | b'3' => 3, 40 | b'4' => 4, 41 | b'5' => 5, 42 | b'6' => 6, 43 | b'7' => 7, 44 | b'8' => 8, 45 | b'9' => 9, 46 | b'a' | b'A' => 10, 47 | b'b' | b'B' => 11, 48 | b'c' | b'C' => 12, 49 | b'd' | b'D' => 13, 50 | b'e' | b'E' => 14, 51 | b'f' | b'F' => 15, 52 | _ => return None, 53 | }) 54 | } 55 | 56 | pub fn merge_hex_halves(first: u8, second: u8) -> Option { 57 | Some(digit_to_byte(first)? * 16 + digit_to_byte(second)?) 58 | } 59 | 60 | #[test] 61 | fn test_merge_halves() { 62 | assert_eq!(merge_hex_halves(b'0', b'0'), Some(0)); 63 | assert_eq!(merge_hex_halves(b'0', b'f'), Some(15)); 64 | assert_eq!(merge_hex_halves(b'3', b'2'), Some(50)); 65 | assert_eq!(merge_hex_halves(b'f', b'0'), Some(240)); 66 | assert_eq!(merge_hex_halves(b'f', b'f'), Some(255)); 67 | } 68 | -------------------------------------------------------------------------------- /src/app/backend_command.rs: -------------------------------------------------------------------------------- 1 | //! This module is similar in purpose to [`crate::app::command`]. 2 | //! 3 | //! See that module for more information. 4 | 5 | use { 6 | super::App, crate::config::Config, egui_sf2g::sf2g::graphics::RenderWindow, 7 | std::collections::VecDeque, 8 | }; 9 | 10 | pub enum BackendCmd { 11 | SetWindowTitle(String), 12 | ApplyVsyncCfg, 13 | ApplyFpsLimit, 14 | } 15 | 16 | /// Gui command queue. 17 | /// 18 | /// Push operations with `push`, and call [`App::flush_backend_command_queue`] when you have 19 | /// exclusive access to the [`App`]. 20 | /// 21 | /// [`App::flush_backend_command_queue`] is called automatically every frame, if you don't need to perform the operations sooner. 22 | #[derive(Default)] 23 | pub struct BackendCommandQueue { 24 | inner: VecDeque, 25 | } 26 | 27 | impl BackendCommandQueue { 28 | pub fn push(&mut self, command: BackendCmd) { 29 | self.inner.push_back(command); 30 | } 31 | } 32 | 33 | impl App { 34 | /// Flush the [`BackendCommandQueue`] and perform all operations queued up. 35 | /// 36 | /// Automatically called every frame, but can be called manually if operations need to be 37 | /// performed sooner. 38 | pub fn flush_backend_command_queue(&mut self, rw: &mut RenderWindow) { 39 | while let Some(cmd) = self.backend_cmd.inner.pop_front() { 40 | perform_command(cmd, rw, &self.cfg); 41 | } 42 | } 43 | } 44 | 45 | fn perform_command(cmd: BackendCmd, rw: &mut RenderWindow, cfg: &Config) { 46 | match cmd { 47 | BackendCmd::SetWindowTitle(title) => rw.set_title(&title), 48 | BackendCmd::ApplyVsyncCfg => { 49 | rw.set_vertical_sync_enabled(cfg.vsync); 50 | } 51 | BackendCmd::ApplyFpsLimit => { 52 | rw.set_framerate_limit(cfg.fps_limit); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/edit_state.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default, Debug)] 2 | pub struct EditState { 3 | // The editing byte offset 4 | pub cursor: usize, 5 | cursor_history: Vec, 6 | cursor_history_current: usize, 7 | } 8 | 9 | impl EditState { 10 | /// Set cursor and save history 11 | pub fn set_cursor(&mut self, offset: usize) { 12 | self.cursor_history.truncate(self.cursor_history_current); 13 | self.cursor_history.push(self.cursor); 14 | self.cursor = offset; 15 | self.cursor_history_current += 1; 16 | } 17 | /// Set cursor, don't save history 18 | pub fn set_cursor_no_history(&mut self, offset: usize) { 19 | self.cursor = offset; 20 | } 21 | /// Step cursor forward without saving history 22 | pub fn step_cursor_forward(&mut self) { 23 | self.cursor += 1; 24 | } 25 | /// Step cursor back without saving history 26 | pub fn step_cursor_back(&mut self) { 27 | self.cursor = self.cursor.saturating_sub(1); 28 | } 29 | /// Offset cursor by amount, not saving history 30 | pub fn offset_cursor(&mut self, amount: usize) { 31 | self.cursor += amount; 32 | } 33 | pub fn cursor_history_back(&mut self) -> bool { 34 | if self.cursor_history_current > 0 { 35 | self.cursor_history.push(self.cursor); 36 | self.cursor_history_current -= 1; 37 | self.cursor = self.cursor_history[self.cursor_history_current]; 38 | true 39 | } else { 40 | false 41 | } 42 | } 43 | pub fn cursor_history_forward(&mut self) -> bool { 44 | if self.cursor_history_current + 1 < self.cursor_history.len() { 45 | self.cursor_history_current += 1; 46 | self.cursor = self.cursor_history[self.cursor_history_current]; 47 | true 48 | } else { 49 | false 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/session_prefs.rs: -------------------------------------------------------------------------------- 1 | /// Preferences that only last during the current session, they are not saved 2 | #[derive(Debug, Default)] 3 | pub struct SessionPrefs { 4 | /// Move the edit cursor with the cursor keys, instead of block cursor 5 | pub move_edit_cursor: bool, 6 | /// Immediately apply changes when editing a value, instead of having 7 | /// to type everything or press enter 8 | pub quick_edit: bool, 9 | /// Don't move the cursor after editing is finished 10 | pub sticky_edit: bool, 11 | /// Automatically save when editing is finished 12 | pub auto_save: bool, 13 | /// Keep metadata when loading. 14 | pub keep_meta: bool, 15 | /// Try to stay on current column when changing column count 16 | pub col_change_lock_col: bool, 17 | /// Try to stay on current row when changing column count 18 | pub col_change_lock_row: bool = true, 19 | /// Background color (mostly for fun) 20 | pub bg_color: [f32; 3] = [0.0; 3], 21 | /// If true, auto-reload the current file at specified interval 22 | pub auto_reload: Autoreload = Autoreload::Disabled, 23 | /// Auto-reload interval in milliseconds 24 | pub auto_reload_interval_ms: u32 = 250, 25 | /// Hide the edit cursor 26 | pub hide_cursor: bool, 27 | } 28 | 29 | /// Autoreload behavior 30 | #[derive(Debug, PartialEq)] 31 | pub enum Autoreload { 32 | /// No autoreload 33 | Disabled, 34 | /// Autoreload all data 35 | All, 36 | /// Only autoreload the data visible in the active layout 37 | Visible, 38 | } 39 | 40 | impl Autoreload { 41 | /// Whether any autoreload is active 42 | pub fn is_active(&self) -> bool { 43 | !matches!(self, Self::Disabled) 44 | } 45 | pub fn label(&self) -> &'static str { 46 | match self { 47 | Self::Disabled => "disabled", 48 | Self::All => "all", 49 | Self::Visible => "visible only", 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/gui/dialogs/auto_save_reload.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Dialog, session_prefs::Autoreload}, 3 | mlua::Lua, 4 | }; 5 | 6 | #[derive(Debug)] 7 | pub struct AutoSaveReloadDialog; 8 | 9 | impl Dialog for AutoSaveReloadDialog { 10 | fn title(&self) -> &str { 11 | "Auto save/reload" 12 | } 13 | 14 | fn ui( 15 | &mut self, 16 | ui: &mut egui::Ui, 17 | app: &mut App, 18 | _gui: &mut crate::gui::Gui, 19 | _lua: &Lua, 20 | _font_size: u16, 21 | _line_spacing: u16, 22 | ) -> bool { 23 | egui::ComboBox::from_label("Auto reload") 24 | .selected_text(app.preferences.auto_reload.label()) 25 | .show_ui(ui, |ui| { 26 | ui.selectable_value( 27 | &mut app.preferences.auto_reload, 28 | Autoreload::Disabled, 29 | Autoreload::Disabled.label(), 30 | ); 31 | ui.selectable_value( 32 | &mut app.preferences.auto_reload, 33 | Autoreload::All, 34 | Autoreload::All.label(), 35 | ); 36 | ui.selectable_value( 37 | &mut app.preferences.auto_reload, 38 | Autoreload::Visible, 39 | Autoreload::Visible.label(), 40 | ); 41 | }); 42 | ui.horizontal(|ui| { 43 | ui.label("Interval (ms)"); 44 | ui.add(egui::DragValue::new( 45 | &mut app.preferences.auto_reload_interval_ms, 46 | )); 47 | }); 48 | ui.separator(); 49 | ui.checkbox(&mut app.preferences.auto_save, "Auto save") 50 | .on_hover_text("Save every time an editing action is finished"); 51 | ui.separator(); 52 | !(ui.button("Close (enter/esc)").clicked() 53 | || ui.input(|inp| inp.key_pressed(egui::Key::Escape)) 54 | || ui.input(|inp| inp.key_pressed(egui::Key::Enter))) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/find_util.rs: -------------------------------------------------------------------------------- 1 | pub fn find_hex_string( 2 | hex_string: &str, 3 | haystack: &[u8], 4 | mut f: impl FnMut(usize), 5 | ) -> anyhow::Result<()> { 6 | let needle = parse_hex_string(hex_string)?; 7 | for offset in memchr::memmem::find_iter(haystack, &needle) { 8 | f(offset); 9 | } 10 | Ok(()) 11 | } 12 | 13 | enum HexStringSepKind { 14 | Comma, 15 | Whitespace, 16 | Dense, 17 | } 18 | 19 | fn detect_hex_string_sep_kind(hex_string: &str) -> HexStringSepKind { 20 | if hex_string.contains(',') { 21 | HexStringSepKind::Comma 22 | } else if hex_string.contains(char::is_whitespace) { 23 | HexStringSepKind::Whitespace 24 | } else { 25 | HexStringSepKind::Dense 26 | } 27 | } 28 | 29 | fn chunks_2(input: &str) -> impl Iterator> { 30 | input 31 | .as_bytes() 32 | .as_chunks::<2>() 33 | .0 34 | .iter() 35 | .map(|pair| std::str::from_utf8(pair).map_err(anyhow::Error::from)) 36 | } 37 | 38 | pub fn parse_hex_string(hex_string: &str) -> anyhow::Result> { 39 | match detect_hex_string_sep_kind(hex_string) { 40 | HexStringSepKind::Comma => { 41 | hex_string.split(',').map(|tok| parse_hex_token(tok.trim())).collect() 42 | } 43 | HexStringSepKind::Whitespace => { 44 | hex_string.split_whitespace().map(parse_hex_token).collect() 45 | } 46 | HexStringSepKind::Dense => chunks_2(hex_string).map(|tok| parse_hex_token(tok?)).collect(), 47 | } 48 | } 49 | 50 | fn parse_hex_token(tok: &str) -> anyhow::Result { 51 | Ok(u8::from_str_radix(tok, 16)?) 52 | } 53 | 54 | #[test] 55 | fn test_parse_hex_string() { 56 | assert_eq!( 57 | parse_hex_string("de ad be ef").unwrap(), 58 | vec![0xde, 0xad, 0xbe, 0xef] 59 | ); 60 | assert_eq!( 61 | parse_hex_string("de, ad, be, ef").unwrap(), 62 | vec![0xde, 0xad, 0xbe, 0xef] 63 | ); 64 | assert_eq!( 65 | parse_hex_string("deadbeef").unwrap(), 66 | vec![0xde, 0xad, 0xbe, 0xef] 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/edit_buffer.rs: -------------------------------------------------------------------------------- 1 | use gamedebug_core::per; 2 | 3 | #[derive(Debug, Default, Clone)] 4 | pub struct EditBuffer { 5 | pub buf: Vec, 6 | pub cursor: u16, 7 | /// Whether this edit buffer has been edited 8 | pub dirty: bool, 9 | } 10 | 11 | impl EditBuffer { 12 | pub(crate) fn resize(&mut self, new_size: u16) { 13 | self.buf.resize(usize::from(new_size), 0); 14 | } 15 | /// Enter a byte. Returns if editing is "finished" (at end) 16 | pub(crate) fn enter_byte(&mut self, byte: u8) -> bool { 17 | self.dirty = true; 18 | self.buf[self.cursor as usize] = byte; 19 | self.cursor += 1; 20 | if usize::from(self.cursor) >= self.buf.len() { 21 | self.reset(); 22 | true 23 | } else { 24 | false 25 | } 26 | } 27 | 28 | pub fn reset(&mut self) { 29 | self.cursor = 0; 30 | self.dirty = false; 31 | } 32 | 33 | pub(crate) fn update_from_string(&mut self, s: &str) { 34 | let bytes = s.as_bytes(); 35 | self.buf[..bytes.len()].copy_from_slice(bytes); 36 | } 37 | /// Returns whether the cursor could be moved any further 38 | pub(crate) fn move_cursor_back(&mut self) -> bool { 39 | if self.cursor == 0 { 40 | false 41 | } else { 42 | self.cursor -= 1; 43 | true 44 | } 45 | } 46 | /// Move the cursor to the end 47 | #[expect( 48 | clippy::cast_possible_truncation, 49 | reason = "Buffer is never bigger than u16::MAX" 50 | )] 51 | pub(crate) fn move_cursor_end(&mut self) { 52 | self.cursor = (self.buf.len() - 1) as u16; 53 | } 54 | 55 | /// Returns whether the cursor could be moved any further 56 | #[expect( 57 | clippy::cast_possible_truncation, 58 | reason = "Buffer is never bigger than u16::MAX" 59 | )] 60 | pub(crate) fn move_cursor_forward(&mut self) -> bool { 61 | if self.cursor >= self.buf.len() as u16 - 1 { 62 | false 63 | } else { 64 | per!("Moving cursor forward, no problem"); 65 | self.cursor += 1; 66 | true 67 | } 68 | } 69 | 70 | pub(crate) fn move_cursor_begin(&mut self) { 71 | self.cursor = 0; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/gui/dialogs/pattern_fill.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | damage_region::DamageRegion, 5 | find_util, 6 | gui::{Dialog, message_dialog::Icon}, 7 | slice_ext::SliceExt as _, 8 | }, 9 | mlua::Lua, 10 | }; 11 | 12 | #[derive(Debug, Default)] 13 | pub struct PatternFillDialog { 14 | pattern_string: String, 15 | just_opened: bool, 16 | } 17 | 18 | impl Dialog for PatternFillDialog { 19 | fn title(&self) -> &str { 20 | "Selection pattern fill" 21 | } 22 | 23 | fn on_open(&mut self) { 24 | self.just_opened = true; 25 | } 26 | 27 | fn ui( 28 | &mut self, 29 | ui: &mut egui::Ui, 30 | app: &mut App, 31 | gui: &mut crate::gui::Gui, 32 | _lua: &Lua, 33 | _font_size: u16, 34 | _line_spacing: u16, 35 | ) -> bool { 36 | let re = ui.add( 37 | egui::TextEdit::singleline(&mut self.pattern_string) 38 | .hint_text("Hex pattern (e.g. `00 ff 00`)"), 39 | ); 40 | if self.just_opened { 41 | re.request_focus(); 42 | } 43 | self.just_opened = false; 44 | if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { 45 | let values: Result, _> = find_util::parse_hex_string(&self.pattern_string); 46 | match values { 47 | Ok(values) => { 48 | for reg in app.hex_ui.selected_regions() { 49 | let range = reg.to_range(); 50 | let Some(data_slice) = app.data.get_mut(range.clone()) else { 51 | gui.msg_dialog.open(Icon::Error, "Pattern fill error", format!("Invalid range for fill.\nRequested range: {range:?}\nData length: {}", app.data.len())); 52 | return false; 53 | }; 54 | data_slice.pattern_fill(&values); 55 | app.data.widen_dirty_region(DamageRegion::RangeInclusive(range)); 56 | } 57 | false 58 | } 59 | Err(e) => { 60 | gui.msg_dialog.open(Icon::Error, "Fill parse error", e.to_string()); 61 | true 62 | } 63 | } 64 | } else { 65 | true 66 | } 67 | } 68 | fn has_close_button(&self) -> bool { 69 | true 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/source.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::File, 3 | io::{Read, Stdin}, 4 | }; 5 | 6 | #[derive(Debug)] 7 | pub enum SourceProvider { 8 | File(File), 9 | Stdin(Stdin), 10 | #[cfg(windows)] 11 | WinProc { 12 | handle: windows_sys::Win32::Foundation::HANDLE, 13 | start: usize, 14 | size: usize, 15 | }, 16 | } 17 | 18 | /// FIXME: Prove this is actually safe 19 | #[cfg(windows)] 20 | unsafe impl Send for SourceProvider {} 21 | 22 | #[derive(Debug)] 23 | pub struct Source { 24 | pub provider: SourceProvider, 25 | pub attr: SourceAttributes, 26 | pub state: SourceState, 27 | } 28 | 29 | impl Source { 30 | pub fn file(f: File) -> Self { 31 | Self { 32 | provider: SourceProvider::File(f), 33 | attr: SourceAttributes { 34 | stream: false, 35 | permissions: SourcePermissions { write: true }, 36 | }, 37 | state: SourceState::default(), 38 | } 39 | } 40 | } 41 | 42 | #[derive(Debug)] 43 | pub struct SourceAttributes { 44 | /// Whether reading should be done by streaming 45 | pub stream: bool, 46 | pub permissions: SourcePermissions, 47 | } 48 | 49 | #[derive(Debug, Default)] 50 | pub struct SourceState { 51 | /// Whether streaming has finished 52 | pub stream_end: bool, 53 | } 54 | 55 | #[derive(Debug)] 56 | pub struct SourcePermissions { 57 | pub write: bool, 58 | } 59 | 60 | impl Clone for SourceProvider { 61 | #[expect( 62 | clippy::unwrap_used, 63 | reason = "Can't really do much else in clone impl" 64 | )] 65 | fn clone(&self) -> Self { 66 | match self { 67 | Self::File(file) => Self::File(file.try_clone().unwrap()), 68 | Self::Stdin(_) => Self::Stdin(std::io::stdin()), 69 | #[cfg(windows)] 70 | Self::WinProc { 71 | handle, 72 | start, 73 | size, 74 | } => Self::WinProc { 75 | handle: *handle, 76 | start: *start, 77 | size: *size, 78 | }, 79 | } 80 | } 81 | } 82 | 83 | impl Read for SourceProvider { 84 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result { 85 | match self { 86 | Self::File(f) => f.read(buf), 87 | Self::Stdin(stdin) => stdin.read(buf), 88 | #[cfg(windows)] 89 | SourceProvider::WinProc { .. } => { 90 | gamedebug_core::per!("Todo: Read unimplemented"); 91 | Ok(0) 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/windows.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | App, 4 | gui::message_dialog::MessageDialog, 5 | source::{Source, SourceAttributes, SourcePermissions, SourceProvider, SourceState}, 6 | }, 7 | anyhow::bail, 8 | windows_sys::Win32::System::Threading::*, 9 | }; 10 | 11 | pub fn load_proc_memory( 12 | app: &mut App, 13 | pid: sysinfo::Pid, 14 | start: usize, 15 | size: usize, 16 | _is_write: bool, 17 | font_size: u16, 18 | line_spacing: u16, 19 | _msg: &mut MessageDialog, 20 | ) -> anyhow::Result<()> { 21 | let handle; 22 | unsafe { 23 | let access = 24 | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION; 25 | handle = OpenProcess(access, 0, pid.as_u32()); 26 | if handle.is_null() { 27 | bail!("Failed to open process."); 28 | } 29 | load_proc_memory_inner(app, handle, start, size, font_size, line_spacing) 30 | } 31 | } 32 | 33 | unsafe fn load_proc_memory_inner( 34 | app: &mut App, 35 | handle: windows_sys::Win32::Foundation::HANDLE, 36 | start: usize, 37 | size: usize, 38 | font_size: u16, 39 | line_spacing: u16, 40 | ) -> anyhow::Result<()> { 41 | unsafe { read_proc_memory(handle, &mut app.data, start, size) }?; 42 | app.source = Some(Source { 43 | attr: SourceAttributes { 44 | permissions: SourcePermissions { write: true }, 45 | stream: false, 46 | }, 47 | provider: SourceProvider::WinProc { 48 | handle, 49 | start, 50 | size, 51 | }, 52 | state: SourceState::default(), 53 | }); 54 | if !app.preferences.keep_meta { 55 | app.set_new_clean_meta(font_size, line_spacing); 56 | } 57 | app.src_args.hard_seek = Some(start); 58 | app.src_args.take = Some(size); 59 | Ok(()) 60 | } 61 | 62 | pub unsafe fn read_proc_memory( 63 | handle: windows_sys::Win32::Foundation::HANDLE, 64 | data: &mut crate::data::Data, 65 | start: usize, 66 | size: usize, 67 | ) -> anyhow::Result<()> { 68 | let mut n_read: usize = 0; 69 | data.resize(size, 0); 70 | if unsafe { 71 | windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory( 72 | handle, 73 | start as _, 74 | data.as_mut_ptr() as *mut std::ffi::c_void, 75 | size, 76 | &mut n_read, 77 | ) 78 | } == 0 79 | { 80 | bail!("Failed to load process memory. Code: {}", unsafe { 81 | windows_sys::Win32::Foundation::GetLastError() 82 | }); 83 | } 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/plugin.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, meta::PerspectiveKey}, 3 | hexerator_plugin_api::{HexeratorHandle, PerspectiveHandle, Plugin, PluginMethod}, 4 | slotmap::{Key as _, KeyData}, 5 | std::path::PathBuf, 6 | }; 7 | 8 | pub struct PluginContainer { 9 | pub path: PathBuf, 10 | pub plugin: Box, 11 | pub methods: Vec, 12 | // Safety: Must be last, fields are dropped in decl order. 13 | pub _lib: libloading::Library, 14 | } 15 | 16 | impl HexeratorHandle for App { 17 | fn debug_log(&self, msg: &str) { 18 | gamedebug_core::per!("{msg}"); 19 | } 20 | 21 | fn get_data(&self, start: usize, end: usize) -> Option<&[u8]> { 22 | self.data.get(start..=end) 23 | } 24 | 25 | fn get_data_mut(&mut self, start: usize, end: usize) -> Option<&mut [u8]> { 26 | self.data.get_mut(start..=end) 27 | } 28 | 29 | fn selection_range(&self) -> Option<[usize; 2]> { 30 | self.hex_ui.selection().map(|sel| [sel.begin, sel.end]) 31 | } 32 | 33 | fn perspective(&self, name: &str) -> Option { 34 | let key = self 35 | .meta_state 36 | .meta 37 | .low 38 | .perspectives 39 | .iter() 40 | .find_map(|(k, per)| (per.name == name).then_some(k))?; 41 | Some(PerspectiveHandle { 42 | key_data: key.data().as_ffi(), 43 | }) 44 | } 45 | 46 | fn perspective_rows(&self, ph: &PerspectiveHandle) -> Vec<&[u8]> { 47 | let key: PerspectiveKey = KeyData::from_ffi(ph.key_data).into(); 48 | let per = &self.meta_state.meta.low.perspectives[key]; 49 | let regs = &self.meta_state.meta.low.regions; 50 | let mut out = Vec::new(); 51 | let n_rows = per.n_rows(regs); 52 | for row_idx in 0..n_rows { 53 | let begin = per.byte_offset_of_row_col(row_idx, 0, regs); 54 | out.push(&self.data[begin..begin + per.cols]); 55 | } 56 | out 57 | } 58 | } 59 | 60 | impl PluginContainer { 61 | pub unsafe fn new(path: PathBuf) -> anyhow::Result { 62 | // Safety: This will cause UB on a bad plugin. Nothing we can do. 63 | // 64 | // It's up to the user not to load bad plugins. 65 | unsafe { 66 | let lib = libloading::Library::new(&path)?; 67 | let plugin_init = lib.get:: Box>(b"hexerator_plugin_new")?; 68 | let plugin = plugin_init(); 69 | Ok(Self { 70 | path, 71 | methods: plugin.methods(), 72 | plugin, 73 | _lib: lib, 74 | }) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/meta/perspective.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::region::Region, 3 | crate::meta::{RegionKey, RegionMap}, 4 | serde::{Deserialize, Serialize}, 5 | }; 6 | 7 | /// A "perspectived" (column count) view of a region 8 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] 9 | pub struct Perspective { 10 | /// The associated region 11 | pub region: RegionKey, 12 | /// Column count, a.k.a alignment. The proper alignment can reveal 13 | /// patterns to the human eye that aren't otherwise easily recognizable. 14 | pub cols: usize, 15 | /// Whether row order is flipped. 16 | /// 17 | /// Sometimes binary files store images or other data "upside-down". 18 | /// A row order flipped perspective helps view and manipulate this kind of data better. 19 | pub flip_row_order: bool, 20 | pub name: String, 21 | } 22 | 23 | impl Perspective { 24 | /// Returns the index of the last row 25 | pub(crate) fn last_row_idx(&self, rmap: &RegionMap) -> usize { 26 | rmap[self.region].region.end / self.cols 27 | } 28 | /// Returns the index of the last column 29 | pub(crate) fn last_col_idx(&self, rmap: &RegionMap) -> usize { 30 | rmap[self.region].region.end % self.cols 31 | } 32 | pub(crate) fn byte_offset_of_row_col(&self, row: usize, col: usize, rmap: &RegionMap) -> usize { 33 | rmap[self.region].region.begin + (row * self.cols + col) 34 | } 35 | pub(crate) fn row_col_of_byte_offset(&self, offset: usize, rmap: &RegionMap) -> [usize; 2] { 36 | let reg = &rmap[self.region]; 37 | let offset = offset.saturating_sub(reg.region.begin); 38 | [offset / self.cols, offset % self.cols] 39 | } 40 | /// Whether the columns are within `cols` and the calculated offset is within the region 41 | pub(crate) fn row_col_within_bound(&self, row: usize, col: usize, rmap: &RegionMap) -> bool { 42 | col < self.cols 43 | && rmap[self.region].region.contains(self.byte_offset_of_row_col(row, col, rmap)) 44 | } 45 | pub(crate) fn clamp_cols(&mut self, rmap: &RegionMap) { 46 | self.cols = self.cols.clamp(1, rmap[self.region].region.len()); 47 | } 48 | /// Returns rows spanned by `region`, and the remainder 49 | pub(crate) fn region_row_span(&self, region: Region) -> [usize; 2] { 50 | [region.len() / self.cols, region.len() % self.cols] 51 | } 52 | pub(crate) fn n_rows(&self, rmap: &RegionMap) -> usize { 53 | let region = &rmap[self.region].region; 54 | let mut rows = region.len() / self.cols; 55 | if !region.len().is_multiple_of(self.cols) { 56 | rows += 1; 57 | } 58 | rows 59 | } 60 | 61 | pub(crate) fn from_region(key: RegionKey, name: String) -> Self { 62 | Self { 63 | region: key, 64 | cols: 48, 65 | flip_row_order: false, 66 | name, 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/gui/dialogs/lua_color.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Dialog, value_color::ColorMethod}, 3 | mlua::{Function, Lua}, 4 | }; 5 | 6 | pub struct LuaColorDialog { 7 | script: String, 8 | err_string: String, 9 | auto_exec: bool, 10 | } 11 | 12 | impl Default for LuaColorDialog { 13 | fn default() -> Self { 14 | const DEFAULT_SCRIPT: &str = 15 | include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/lua/color.lua")); 16 | Self { 17 | script: DEFAULT_SCRIPT.into(), 18 | err_string: String::new(), 19 | auto_exec: Default::default(), 20 | } 21 | } 22 | } 23 | 24 | impl Dialog for LuaColorDialog { 25 | fn title(&self) -> &str { 26 | "Lua color" 27 | } 28 | 29 | fn ui( 30 | &mut self, 31 | ui: &mut egui::Ui, 32 | app: &mut App, 33 | _gui: &mut crate::gui::Gui, 34 | lua: &Lua, 35 | _font_size: u16, 36 | _line_spacing: u16, 37 | ) -> bool { 38 | let color_data = match app.hex_ui.focused_view { 39 | Some(view_key) => { 40 | let view = &mut app.meta_state.meta.views[view_key].view; 41 | match &mut view.presentation.color_method { 42 | ColorMethod::Custom(color_data) => &mut color_data.0, 43 | _ => { 44 | ui.label("Please select \"Custom\" as color scheme for the current view"); 45 | return !ui.button("Close").clicked(); 46 | } 47 | } 48 | } 49 | None => { 50 | ui.label("No active view"); 51 | return !ui.button("Close").clicked(); 52 | } 53 | }; 54 | egui::TextEdit::multiline(&mut self.script) 55 | .code_editor() 56 | .desired_width(f32::INFINITY) 57 | .show(ui); 58 | ui.horizontal(|ui| { 59 | if ui.button("Execute").clicked() || self.auto_exec { 60 | let chunk = lua.load(&self.script); 61 | let res = try { 62 | let fun = chunk.eval::()?; 63 | for (i, c) in color_data.iter_mut().enumerate() { 64 | let rgb: [u8; 3] = fun.call((i,))?; 65 | *c = rgb; 66 | } 67 | }; 68 | if let Err(e) = res { 69 | self.err_string = e.to_string(); 70 | } else { 71 | self.err_string.clear(); 72 | } 73 | } 74 | ui.checkbox(&mut self.auto_exec, "Auto execute"); 75 | }); 76 | if !self.err_string.is_empty() { 77 | ui.label(egui::RichText::new(&self.err_string).color(egui::Color32::RED)); 78 | } 79 | if ui.button("Close").clicked() { 80 | return false; 81 | } 82 | true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/gui/command.rs: -------------------------------------------------------------------------------- 1 | //! This module is similar in purpose to [`crate::app::command`]. 2 | //! 3 | //! See that module for more information. 4 | 5 | use { 6 | super::Gui, 7 | crate::shell::msg_fail, 8 | std::{collections::VecDeque, process::Command}, 9 | sysinfo::ProcessesToUpdate, 10 | }; 11 | 12 | pub enum GCmd { 13 | OpenPerspectiveWindow, 14 | /// Spawn a command with optional arguments. Must not be an empty vector. 15 | SpawnCommand { 16 | args: Vec, 17 | /// If `Some`, don't focus a pid, just filter for this process in the list. 18 | /// 19 | /// The idea is that if your command spawns a child process, it might not spawn immediately, 20 | /// so the user can wait for it to appear on the process list, with the applied filter. 21 | look_for_proc: Option, 22 | }, 23 | } 24 | 25 | /// Gui command queue. 26 | /// 27 | /// Push operations with `push`, and call [`Gui::flush_command_queue`] when you have 28 | /// exclusive access to the [`Gui`]. 29 | /// 30 | /// [`Gui::flush_command_queue`] is called automatically every frame, if you don't need to perform the operations sooner. 31 | #[derive(Default)] 32 | pub struct GCommandQueue { 33 | inner: VecDeque, 34 | } 35 | 36 | impl GCommandQueue { 37 | pub fn push(&mut self, command: GCmd) { 38 | self.inner.push_back(command); 39 | } 40 | } 41 | 42 | impl Gui { 43 | /// Flush the [`GCommandQueue`] and perform all operations queued up. 44 | /// 45 | /// Automatically called every frame, but can be called manually if operations need to be 46 | /// performed sooner. 47 | pub fn flush_command_queue(&mut self) { 48 | while let Some(cmd) = self.cmd.inner.pop_front() { 49 | perform_command(self, cmd); 50 | } 51 | } 52 | } 53 | 54 | fn perform_command(gui: &mut Gui, cmd: GCmd) { 55 | match cmd { 56 | GCmd::OpenPerspectiveWindow => gui.win.perspectives.open.set(true), 57 | GCmd::SpawnCommand { 58 | mut args, 59 | look_for_proc, 60 | } => { 61 | let cmd = args.remove(0); 62 | match Command::new(cmd).args(args).spawn() { 63 | Ok(child) => { 64 | gui.win.open_process.open.set(true); 65 | match look_for_proc { 66 | Some(procname) => { 67 | gui.win 68 | .open_process 69 | .sys 70 | .refresh_processes(ProcessesToUpdate::All, true); 71 | gui.win.open_process.filters.proc_name = procname; 72 | } 73 | None => { 74 | gui.win.open_process.selected_pid = 75 | Some(sysinfo::Pid::from_u32(child.id())); 76 | } 77 | } 78 | } 79 | Err(e) => { 80 | msg_fail(&e, "Failed to spawn command", &mut gui.msg_dialog); 81 | } 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/gui/dialogs/x86_asm.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Dialog}, 3 | egui::Button, 4 | iced_x86::{Decoder, Formatter as _, NasmFormatter}, 5 | mlua::Lua, 6 | }; 7 | 8 | pub struct X86AsmDialog { 9 | decoded: Vec, 10 | bitness: u32, 11 | } 12 | 13 | impl X86AsmDialog { 14 | pub fn new() -> Self { 15 | Self { 16 | decoded: Vec::new(), 17 | bitness: 64, 18 | } 19 | } 20 | } 21 | 22 | impl Dialog for X86AsmDialog { 23 | fn title(&self) -> &str { 24 | "X86 assembly" 25 | } 26 | 27 | fn ui( 28 | &mut self, 29 | ui: &mut egui::Ui, 30 | app: &mut App, 31 | _gui: &mut crate::gui::Gui, 32 | _lua: &Lua, 33 | _font_size: u16, 34 | _line_spacing: u16, 35 | ) -> bool { 36 | let mut retain = true; 37 | egui::ScrollArea::vertical() 38 | .auto_shrink(false) 39 | .max_height(320.0) 40 | .show(ui, |ui| { 41 | egui::Grid::new("asm_grid").num_columns(2).show(ui, |ui| { 42 | for instr in &self.decoded { 43 | let Some(sel_begin) = app.hex_ui.selection().map(|sel| sel.begin) else { 44 | ui.label("No selection"); 45 | return; 46 | }; 47 | let instr_off = instr.offset + sel_begin; 48 | if ui.link(instr_off.to_string()).clicked() { 49 | app.search_focus(instr_off); 50 | } 51 | ui.label(&instr.string); 52 | ui.end_row(); 53 | } 54 | }); 55 | }); 56 | ui.separator(); 57 | match app.hex_ui.selection() { 58 | Some(sel) => { 59 | if ui.button("Disassemble").clicked() { 60 | self.decoded = disasm(&app.data[sel.begin..=sel.end], self.bitness); 61 | } 62 | } 63 | None => { 64 | ui.add_enabled(false, Button::new("Disassemble")); 65 | } 66 | } 67 | ui.horizontal(|ui| { 68 | ui.label("Bitness"); 69 | ui.radio_value(&mut self.bitness, 16, "16"); 70 | ui.radio_value(&mut self.bitness, 32, "32"); 71 | ui.radio_value(&mut self.bitness, 64, "64"); 72 | }); 73 | if ui.button("Close").clicked() { 74 | retain = false; 75 | } 76 | retain 77 | } 78 | } 79 | 80 | struct DecodedInstr { 81 | string: String, 82 | offset: usize, 83 | } 84 | 85 | fn disasm(data: &[u8], bitness: u32) -> Vec { 86 | let mut decoder = Decoder::new(bitness, data, 0); 87 | let mut fmt = NasmFormatter::default(); 88 | let mut vec = Vec::new(); 89 | while decoder.can_decode() { 90 | let offset = decoder.position(); 91 | let instr = decoder.decode(); 92 | let mut string = String::new(); 93 | fmt.format(&instr, &mut string); 94 | vec.push(DecodedInstr { string, offset }); 95 | } 96 | vec 97 | } 98 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hexerator" 3 | version = "0.5.0-dev" 4 | edition = "2024" 5 | license = "MIT OR Apache-2.0" 6 | 7 | [features] 8 | backend-sfml = ["dep:egui-sf2g", "dep:sf2g"] 9 | default = ["backend-sfml"] 10 | 11 | [dependencies] 12 | gamedebug_core = { git = "https://github.com/crumblingstatue/gamedebug_core.git" } 13 | clap = { version = "4.5.4", features = ["derive"] } 14 | anyhow = "1.0.81" 15 | rand = "0.9.0" 16 | rmp-serde = "1.1.2" 17 | serde = { version = "1.0.197", features = ["derive"] } 18 | directories = "6.0.0" 19 | recently_used_list = { git = "https://github.com/crumblingstatue/recently_used_list.git" } 20 | memchr = "2.7.2" 21 | glu-sys = "0.1.4" 22 | thiserror = "2" 23 | either = "1.10.0" 24 | tree_magic_mini = "3.1.6" 25 | slotmap = { version = "1.0.7", features = ["serde"] } 26 | egui-sf2g = { version = "0.5", optional = true } 27 | sf2g = { version = "0.4", optional = true, features = ["text"] } 28 | num-traits = "0.2.18" 29 | serde-big-array = "0.5.1" 30 | egui = { version = "0.32", features = ["serde"] } 31 | egui_extras = { version = "0.32", default-features = false } 32 | itertools = "0.14" 33 | sysinfo = { version = "0.36", default-features = false, features = ["system"] } 34 | proc-maps = "0.4.0" 35 | open = "5.1.2" 36 | arboard = { version = "3.6.0", default-features = false } 37 | paste = "1.0.14" 38 | iced-x86 = "1.21.0" 39 | strum = { version = "0.27", features = ["derive"] } 40 | egui_code_editor = "0.2.14" 41 | # luajit breaks with panic=abort, because it relies on unwinding for exception handling 42 | mlua = { version = "0.11", features = ["luau", "vendored"] } 43 | egui-file-dialog.git = "https://github.com/jannistpl/egui-file-dialog.git" 44 | human_bytes = "0.4.3" 45 | shlex = "1.3.0" 46 | egui-fontcfg = { git = "https://github.com/crumblingstatue/egui-fontcfg.git" } 47 | egui_colors = "0.9.0" 48 | libloading = "0.8.3" 49 | hexerator-plugin-api = { path = "hexerator-plugin-api" } 50 | image.version = "0.25" 51 | image.default-features = false 52 | image.features = ["png", "bmp"] 53 | structparse = { git = "https://github.com/crumblingstatue/structparse.git" } 54 | memmap2 = "0.9.5" 55 | egui-phosphor = "0.10.0" 56 | constcat = "0.6.0" 57 | 58 | [target."cfg(windows)".dependencies.windows-sys] 59 | version = "0.59.0" 60 | features = [ 61 | "Win32_System_Diagnostics_Debug", 62 | "Win32_Foundation", 63 | "Win32_System_Threading", 64 | ] 65 | 66 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 67 | 68 | # Uncomment in case incremental compilation breaks things 69 | #[profile.dev] 70 | #incremental = false # Buggy rustc is breaking code with incremental compilation 71 | 72 | # Compile deps with optimizations in dev mode 73 | [profile.dev.package."*"] 74 | opt-level = 2 75 | 76 | [profile.dev] 77 | panic = "abort" 78 | 79 | [profile.release] 80 | panic = "abort" 81 | lto = "thin" 82 | codegen-units = 1 83 | 84 | [build-dependencies] 85 | vergen-gitcl = { version = "1.0.0", default-features = false, features = [ 86 | "build", 87 | "cargo", 88 | "rustc", 89 | ] } 90 | 91 | [workspace] 92 | members = ["hexerator-plugin-api", "plugins/hello-world"] 93 | exclude = ["scripts"] 94 | -------------------------------------------------------------------------------- /src/gui/top_menu/analysis.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | gui::{Gui, message_dialog::Icon}, 5 | shell::msg_if_fail, 6 | }, 7 | constcat::concat, 8 | egui_phosphor::regular as ic, 9 | }; 10 | 11 | const L_DETERMINE_DATA_MIME: &str = 12 | concat!(ic::SEAL_QUESTION, " Determine data mime type under cursor"); 13 | const L_DETERMINE_DATA_MIME_SEL: &str = 14 | concat!(ic::SEAL_QUESTION, " Determine data mime type of selection"); 15 | const L_DIFF_WITH_FILE: &str = concat!(ic::GIT_DIFF, " Diff with file..."); 16 | const L_DIFF_WITH_SOURCE_FILE: &str = concat!(ic::GIT_DIFF, " Diff with source file"); 17 | const L_DIFF_WITH_BACKUP: &str = concat!(ic::GIT_DIFF, " Diff with backup"); 18 | const L_FIND_MEMORY_POINTERS: &str = concat!(ic::ARROW_UP_RIGHT, " Find memory pointers..."); 19 | const L_ZERO_PARTITION: &str = concat!(ic::BINARY, " Zero partition..."); 20 | 21 | pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &App) { 22 | if ui.button(L_DETERMINE_DATA_MIME).clicked() { 23 | gui.msg_dialog.open( 24 | Icon::Info, 25 | "Data mime type under cursor", 26 | tree_magic_mini::from_u8(&app.data[app.edit_state.cursor..]).to_string(), 27 | ); 28 | } 29 | if let Some(region) = app.hex_ui.selection() 30 | && ui.button(L_DETERMINE_DATA_MIME_SEL).clicked() 31 | { 32 | gui.msg_dialog.open( 33 | Icon::Info, 34 | "Data mime type of selection", 35 | tree_magic_mini::from_u8(&app.data[region.begin..=region.end]).to_string(), 36 | ); 37 | } 38 | ui.separator(); 39 | if ui.button(L_DIFF_WITH_FILE).clicked() { 40 | gui.fileops.diff_with_file(app.source_file()); 41 | } 42 | if ui.button(L_DIFF_WITH_SOURCE_FILE).clicked() 43 | && let Some(path) = app.source_file() 44 | { 45 | let path = path.to_owned(); 46 | msg_if_fail( 47 | app.diff_with_file(path, &mut gui.win.file_diff_result), 48 | "Failed to diff", 49 | &mut gui.msg_dialog, 50 | ); 51 | } 52 | match app.backup_path() { 53 | Some(path) if path.exists() => { 54 | if ui.button(L_DIFF_WITH_BACKUP).clicked() { 55 | msg_if_fail( 56 | app.diff_with_file(path, &mut gui.win.file_diff_result), 57 | "Failed to diff", 58 | &mut gui.msg_dialog, 59 | ); 60 | } 61 | } 62 | _ => { 63 | ui.add_enabled(false, egui::Button::new(L_DIFF_WITH_BACKUP)); 64 | } 65 | } 66 | ui.separator(); 67 | if ui 68 | .add_enabled( 69 | gui.win.open_process.selected_pid.is_some(), 70 | egui::Button::new(L_FIND_MEMORY_POINTERS), 71 | ) 72 | .on_disabled_hover_text("Requires open process") 73 | .clicked() 74 | { 75 | gui.win.find_memory_pointers.open.toggle(); 76 | } 77 | if ui 78 | .button(L_ZERO_PARTITION) 79 | .on_hover_text("Find regions of non-zero data separated by zeroed regions") 80 | .clicked() 81 | { 82 | gui.win.zero_partition.open.toggle(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /plugins/hello-world/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Hexerator hello world example plugin 2 | 3 | use hexerator_plugin_api::{ 4 | HexeratorHandle, MethodParam, MethodResult, Plugin, PluginMethod, Value, ValueTy, 5 | }; 6 | 7 | struct HelloPlugin; 8 | 9 | impl Plugin for HelloPlugin { 10 | fn name(&self) -> &str { 11 | "Hello world plugin" 12 | } 13 | 14 | fn desc(&self) -> &str { 15 | "Hi! I'm an example plugin for Hexerator" 16 | } 17 | 18 | fn methods(&self) -> Vec { 19 | vec![ 20 | PluginMethod { 21 | method_name: "say_hello", 22 | human_name: Some("Say hello"), 23 | desc: "Write 'hello' to debug log.", 24 | params: &[], 25 | }, 26 | PluginMethod { 27 | method_name: "fill_selection", 28 | human_name: Some("Fill selection"), 29 | desc: "Fills the selection with 0x42", 30 | params: &[], 31 | }, 32 | PluginMethod { 33 | method_name: "sum_range", 34 | human_name: None, 35 | desc: "Sums up the values in the provided range", 36 | params: &[ 37 | MethodParam { 38 | name: "from", 39 | ty: ValueTy::U64, 40 | }, 41 | MethodParam { 42 | name: "to", 43 | ty: ValueTy::U64, 44 | }, 45 | ], 46 | }, 47 | ] 48 | } 49 | 50 | fn on_method_called( 51 | &mut self, 52 | name: &str, 53 | params: &[Option], 54 | hx: &mut dyn HexeratorHandle, 55 | ) -> MethodResult { 56 | match name { 57 | "say_hello" => { 58 | hx.debug_log("Hello world!"); 59 | Ok(None) 60 | } 61 | "fill_selection" => match hx.selection_range() { 62 | Some([start, end]) => match hx.get_data_mut(start, end) { 63 | Some(data) => { 64 | data.fill(0x42); 65 | Ok(None) 66 | } 67 | None => Err("Selection out of bounds".into()), 68 | }, 69 | None => Err("Selection unavailable".into()), 70 | }, 71 | "sum_range" => { 72 | let &[Some(Value::U64(from)), Some(Value::U64(to))] = params else { 73 | return Err("Invalid params".into()); 74 | }; 75 | match hx.get_data_mut(from as usize, to as usize) { 76 | Some(data) => { 77 | let sum: u64 = data.iter().map(|b| *b as u64).sum(); 78 | Ok(Some(Value::U64(sum))) 79 | } 80 | None => Err("Out of bounds".into()), 81 | } 82 | } 83 | _ => Err(format!("Unknown method: {name}")), 84 | } 85 | } 86 | } 87 | 88 | #[unsafe(no_mangle)] 89 | pub extern "Rust" fn hexerator_plugin_new() -> Box { 90 | Box::new(HelloPlugin) 91 | } 92 | -------------------------------------------------------------------------------- /src/gui/dialogs/jump.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | gui::Dialog, 5 | parse_radix::{Relativity, parse_offset_maybe_relative}, 6 | shell::msg_fail, 7 | }, 8 | mlua::Lua, 9 | }; 10 | 11 | #[derive(Debug, Default)] 12 | pub struct JumpDialog { 13 | string_buf: String, 14 | absolute: bool, 15 | just_opened: bool, 16 | } 17 | 18 | impl Dialog for JumpDialog { 19 | fn title(&self) -> &str { 20 | "Jump" 21 | } 22 | 23 | fn on_open(&mut self) { 24 | self.just_opened = true; 25 | } 26 | 27 | fn ui( 28 | &mut self, 29 | ui: &mut egui::Ui, 30 | app: &mut App, 31 | gui: &mut crate::gui::Gui, 32 | _lua: &Lua, 33 | _font_size: u16, 34 | _line_spacing: u16, 35 | ) -> bool { 36 | ui.horizontal(|ui| { 37 | ui.label("Offset"); 38 | let re = ui.text_edit_singleline(&mut self.string_buf); 39 | if self.just_opened { 40 | re.request_focus(); 41 | } 42 | }); 43 | self.just_opened = false; 44 | ui.label( 45 | "Accepts both decimal and hexadecimal.\nPrefix with `0x` to force hex.\n\ 46 | Prefix with `+` to add to current offset, `-` to subtract", 47 | ); 48 | if let Some(hard_seek) = app.src_args.hard_seek { 49 | ui.checkbox(&mut self.absolute, "Absolute") 50 | .on_hover_text("Subtract the offset from hard-seek"); 51 | let label = format!("hard-seek is at {hard_seek} (0x{hard_seek:X})"); 52 | ui.text_edit_multiline(&mut &label[..]); 53 | } 54 | if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { 55 | // Just close the dialog without error on empty text input 56 | if self.string_buf.trim().is_empty() { 57 | return false; 58 | } 59 | match parse_offset_maybe_relative(&self.string_buf) { 60 | Ok((offset, relativity)) => { 61 | let offset = match relativity { 62 | Relativity::Absolute => { 63 | if let Some(hard_seek) = app.src_args.hard_seek 64 | && self.absolute 65 | { 66 | offset.saturating_sub(hard_seek) 67 | } else { 68 | offset 69 | } 70 | } 71 | Relativity::RelAdd => app.edit_state.cursor.saturating_add(offset), 72 | Relativity::RelSub => app.edit_state.cursor.saturating_sub(offset), 73 | }; 74 | app.edit_state.cursor = offset; 75 | app.center_view_on_offset(offset); 76 | app.hex_ui.flash_cursor(); 77 | false 78 | } 79 | Err(e) => { 80 | msg_fail(&e, "Failed to parse offset", &mut gui.msg_dialog); 81 | true 82 | } 83 | } 84 | } else { 85 | !(ui.input(|inp| inp.key_pressed(egui::Key::Escape))) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/gui/windows/vars.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::meta::{VarEntry, VarVal}, 4 | egui::TextBuffer as _, 5 | egui_extras::Column, 6 | }; 7 | 8 | #[derive(Default)] 9 | pub struct VarsWindow { 10 | pub open: WindowOpen, 11 | pub new_var_name: String, 12 | pub new_val_val: VarVal = VarVal::U64(0), 13 | } 14 | 15 | impl super::Window for VarsWindow { 16 | fn ui(&mut self, WinCtx { ui, app, .. }: WinCtx) { 17 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 18 | ui.group(|ui| { 19 | ui.label("New"); 20 | ui.horizontal(|ui| { 21 | ui.label("Name"); 22 | ui.text_edit_singleline(&mut self.new_var_name); 23 | ui.label("Type"); 24 | let sel_txt = var_val_label(&self.new_val_val); 25 | egui::ComboBox::new("type_select", "Type").selected_text(sel_txt).show_ui( 26 | ui, 27 | |ui| { 28 | ui.selectable_value(&mut self.new_val_val, VarVal::U64(0), "U64"); 29 | ui.selectable_value(&mut self.new_val_val, VarVal::I64(0), "I64"); 30 | }, 31 | ); 32 | if ui.button("Add").clicked() { 33 | app.meta_state.meta.vars.insert( 34 | self.new_var_name.take(), 35 | VarEntry { 36 | val: self.new_val_val.clone(), 37 | desc: String::new(), 38 | }, 39 | ); 40 | } 41 | }); 42 | }); 43 | egui_extras::TableBuilder::new(ui) 44 | .columns(Column::auto(), 4) 45 | .resizable(true) 46 | .header(32.0, |mut row| { 47 | row.col(|ui| { 48 | ui.label("Name"); 49 | }); 50 | row.col(|ui| { 51 | ui.label("Type"); 52 | }); 53 | row.col(|ui| { 54 | ui.label("Description"); 55 | }); 56 | row.col(|ui| { 57 | ui.label("Value"); 58 | }); 59 | }) 60 | .body(|mut body| { 61 | for (key, var_ent) in &mut app.meta_state.meta.vars { 62 | body.row(32.0, |mut row| { 63 | row.col(|ui| { 64 | ui.label(key); 65 | }); 66 | row.col(|ui| { 67 | ui.label(var_val_label(&var_ent.val)); 68 | }); 69 | row.col(|ui| { 70 | ui.text_edit_singleline(&mut var_ent.desc); 71 | }); 72 | row.col(|ui| { 73 | match &mut var_ent.val { 74 | VarVal::I64(var) => ui.add(egui::DragValue::new(var)), 75 | VarVal::U64(var) => ui.add(egui::DragValue::new(var)), 76 | }; 77 | }); 78 | }); 79 | } 80 | }); 81 | } 82 | 83 | fn title(&self) -> &str { 84 | "Variables" 85 | } 86 | } 87 | 88 | fn var_val_label(var_val: &VarVal) -> &str { 89 | match var_val { 90 | VarVal::I64(_) => "i64", 91 | VarVal::U64(_) => "u64", 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/gui/dialogs/lua_fill.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Dialog, shell::msg_if_fail}, 3 | egui_code_editor::{CodeEditor, Syntax}, 4 | mlua::{Function, Lua}, 5 | std::time::Instant, 6 | }; 7 | 8 | #[derive(Debug, Default)] 9 | pub struct LuaFillDialog { 10 | result_info_string: String, 11 | err: bool, 12 | } 13 | 14 | impl Dialog for LuaFillDialog { 15 | fn title(&self) -> &str { 16 | "Lua fill" 17 | } 18 | 19 | fn ui( 20 | &mut self, 21 | ui: &mut egui::Ui, 22 | app: &mut App, 23 | gui: &mut crate::gui::Gui, 24 | lua: &Lua, 25 | _font_size: u16, 26 | _line_spacing: u16, 27 | ) -> bool { 28 | let Some(sel) = app.hex_ui.selection() else { 29 | ui.heading("No active selection"); 30 | return !ui.button("Close").clicked(); 31 | }; 32 | let ctrl_enter = 33 | ui.input_mut(|inp| inp.consume_key(egui::Modifiers::CTRL, egui::Key::Enter)); 34 | 35 | let ctrl_s = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::CTRL, egui::Key::S)); 36 | if ctrl_s { 37 | msg_if_fail( 38 | app.save(&mut gui.msg_dialog), 39 | "Failed to save", 40 | &mut gui.msg_dialog, 41 | ); 42 | } 43 | egui::ScrollArea::vertical() 44 | // 100.0 is an estimation of ui size below. 45 | // If we don't subtract that, the text edit tries to expand 46 | // beyond window height 47 | .max_height(ui.available_height() - 100.0) 48 | .show(ui, |ui| { 49 | CodeEditor::default() 50 | .with_syntax(Syntax::lua()) 51 | .show(ui, &mut app.meta_state.meta.misc.fill_lua_script); 52 | }); 53 | if ui.button("Execute").clicked() || ctrl_enter { 54 | let start_time = Instant::now(); 55 | let chunk = lua.load(&app.meta_state.meta.misc.fill_lua_script); 56 | let res = try { 57 | let f = chunk.eval::()?; 58 | for (i, b) in app.data[sel.begin..=sel.end].iter_mut().enumerate() { 59 | *b = f.call((i, *b))?; 60 | } 61 | app.data.dirty_region = Some(sel); 62 | }; 63 | if let Err(e) = res { 64 | self.result_info_string = e.to_string(); 65 | self.err = true; 66 | } else { 67 | self.result_info_string = 68 | format!("Script took {} ms", start_time.elapsed().as_millis()); 69 | self.err = false; 70 | } 71 | } 72 | if app.data.dirty_region.is_some() { 73 | ui.label( 74 | egui::RichText::new("Unsaved changes") 75 | .italics() 76 | .color(egui::Color32::YELLOW) 77 | .code(), 78 | ); 79 | } else { 80 | ui.label(egui::RichText::new("No unsaved changes").color(egui::Color32::GREEN).code()); 81 | } 82 | ui.label("ctrl+enter to execute, ctrl+s to save file"); 83 | if !self.result_info_string.is_empty() { 84 | if self.err { 85 | ui.label(egui::RichText::new(&self.result_info_string).color(egui::Color32::RED)); 86 | } else { 87 | ui.label(&self.result_info_string); 88 | } 89 | } 90 | true 91 | } 92 | fn has_close_button(&self) -> bool { 93 | true 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/hex_ui.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::interact_mode::InteractMode, 4 | color::RgbaColor, 5 | meta::{LayoutKey, ViewKey, region::Region}, 6 | timer::Timer, 7 | view::ViewportRect, 8 | }, 9 | slotmap::Key as _, 10 | std::{collections::HashMap, time::Duration}, 11 | }; 12 | 13 | /// State related to the hex view ui, different from the egui gui overlay 14 | #[derive(Default)] 15 | pub struct HexUi { 16 | /// "a" point of selection. Could be smaller or larger than "b". 17 | /// The length of selection is absolute difference between a and b 18 | pub select_a: Option, 19 | /// "b" point of selection. Could be smaller or larger than "a". 20 | /// The length of selection is absolute difference between a and b 21 | pub select_b: Option, 22 | /// Extra selections on top of the a-b selection 23 | pub extra_selections: Vec, 24 | pub interact_mode: InteractMode = InteractMode::View, 25 | pub current_layout: LayoutKey, 26 | /// The currently focused view (appears with a yellow border around it) 27 | #[doc(alias = "current_view")] 28 | pub focused_view: Option, 29 | /// The rectangle area that's available for the hex interface 30 | pub hex_iface_rect: ViewportRect, 31 | pub flash_cursor_timer: Timer, 32 | /// Whether to scissor views when drawing them. Useful to disable when debugging rendering. 33 | pub scissor_views: bool = true, 34 | /// When alt is being held, it shows things like names of views as overlays 35 | pub show_alt_overlay: bool, 36 | pub rulers: HashMap, 37 | /// If `Some`, contains the last byte offset the cursor was clicked at, while lmb is being held down 38 | pub lmb_drag_offset: Option, 39 | } 40 | 41 | #[derive(Default)] 42 | pub struct Ruler { 43 | pub color: RgbaColor = RgbaColor { r: 255, g: 255, b: 0,a: 255}, 44 | /// Horizontal offset in pixels 45 | pub hoffset: i16, 46 | /// Frequency of ruler lines 47 | pub freq: u8 = 1, 48 | /// If set, it will try to layout ruler based on the struct fields 49 | pub struct_idx: Option, 50 | } 51 | 52 | impl HexUi { 53 | pub fn selection(&self) -> Option { 54 | if let Some(a) = self.select_a 55 | && let Some(b) = self.select_b 56 | { 57 | Some(Region { 58 | begin: a.min(b), 59 | end: a.max(b), 60 | }) 61 | } else { 62 | None 63 | } 64 | } 65 | pub fn selected_regions(&self) -> impl Iterator { 66 | self.selection().into_iter().chain(self.extra_selections.iter().cloned()) 67 | } 68 | pub fn clear_selections(&mut self) { 69 | self.select_a = None; 70 | self.select_b = None; 71 | self.extra_selections.clear(); 72 | } 73 | /// Clear existing meta references 74 | pub fn clear_meta_refs(&mut self) { 75 | self.current_layout = LayoutKey::null(); 76 | self.focused_view = None; 77 | } 78 | 79 | pub fn flash_cursor(&mut self) { 80 | self.flash_cursor_timer = Timer::set(Duration::from_millis(1500)); 81 | } 82 | 83 | /// If the cursor should be flashing, returns a timer value that can be used to color cursor 84 | pub fn cursor_flash_timer(&self) -> Option { 85 | #[expect( 86 | clippy::cast_possible_truncation, 87 | reason = " 88 | The duration will never be higher than u32 limit. 89 | 90 | It doesn't make sense to set the cursor timer to extremely high values, 91 | only a few seconds at most. 92 | " 93 | )] 94 | self.flash_cursor_timer.overtime().map(|dur| dur.as_millis() as u32) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/gui/top_menu/plugins.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::App, 3 | gui::{Gui, message_dialog::Icon}, 4 | plugin::PluginContainer, 5 | shell::msg_fail, 6 | }; 7 | 8 | pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App) { 9 | let mut plugins = std::mem::take(&mut app.plugins); 10 | let mut reload = None; 11 | if plugins.is_empty() { 12 | ui.add_enabled(false, egui::Label::new("No plugins loaded")); 13 | } 14 | plugins.retain_mut(|plugin| { 15 | let mut retain = true; 16 | ui.horizontal(|ui| { 17 | ui.label(plugin.plugin.name()).on_hover_text(plugin.plugin.desc()); 18 | if ui.button("🗑").on_hover_text("Unload").clicked() { 19 | retain = false; 20 | } 21 | if ui.button("↺").on_hover_text("Reload").clicked() { 22 | retain = false; 23 | reload = Some(plugin.path.clone()); 24 | } 25 | }); 26 | for method in &plugin.methods { 27 | let name = if let Some(name) = method.human_name { 28 | name 29 | } else { 30 | method.method_name 31 | }; 32 | let hover_ui = |ui: &mut egui::Ui| { 33 | ui.horizontal(|ui| { 34 | ui.style_mut().spacing.item_spacing.x = 0.; 35 | ui.label( 36 | egui::RichText::new(method.method_name) 37 | .strong() 38 | .color(egui::Color32::WHITE), 39 | ); 40 | ui.label(egui::RichText::new("(").strong().color(egui::Color32::WHITE)); 41 | for param in method.params { 42 | ui.label(format!("{}: {},", param.name, param.ty.label())); 43 | } 44 | ui.label(egui::RichText::new(")").strong().color(egui::Color32::WHITE)); 45 | }); 46 | ui.indent("indent", |ui| { 47 | ui.label(method.desc); 48 | }); 49 | }; 50 | if ui.button(name).on_hover_ui(hover_ui).clicked() { 51 | match plugin.plugin.on_method_called(method.method_name, &[], app) { 52 | Ok(val) => { 53 | if let Some(val) = val { 54 | let strval = match val { 55 | hexerator_plugin_api::Value::U64(n) => n.to_string(), 56 | hexerator_plugin_api::Value::String(s) => s.to_string(), 57 | hexerator_plugin_api::Value::F64(n) => n.to_string(), 58 | }; 59 | gui.msg_dialog.open( 60 | Icon::Info, 61 | "Method call result", 62 | format!("{}: {}", method.method_name, strval), 63 | ); 64 | } 65 | } 66 | Err(e) => { 67 | msg_fail(&e, "Method call failed", &mut gui.msg_dialog); 68 | } 69 | } 70 | } 71 | } 72 | retain 73 | }); 74 | if let Some(path) = reload { 75 | // Safety: This will cause UB on a bad plugin. Nothing we can do. 76 | // 77 | // It's up to the user not to load bad plugins. 78 | unsafe { 79 | match PluginContainer::new(path) { 80 | Ok(plugin) => { 81 | plugins.push(plugin); 82 | } 83 | Err(e) => msg_fail(&e, "Failed to reload plugin", &mut gui.msg_dialog), 84 | } 85 | } 86 | } 87 | std::mem::swap(&mut app.plugins, &mut plugins); 88 | } 89 | -------------------------------------------------------------------------------- /src/gui/dialogs/truncate.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Dialog, meta::region::Region}, 3 | egui::{Button, DragValue}, 4 | mlua::Lua, 5 | }; 6 | 7 | pub struct TruncateDialog { 8 | begin: usize, 9 | end: usize, 10 | } 11 | 12 | impl TruncateDialog { 13 | pub fn new(data_len: usize, selection: Option) -> Self { 14 | let (begin, end) = match selection { 15 | Some(region) => (region.begin, region.end), 16 | None => (0, data_len.saturating_sub(1)), 17 | }; 18 | Self { begin, end } 19 | } 20 | } 21 | 22 | impl Dialog for TruncateDialog { 23 | fn title(&self) -> &str { 24 | "Truncate/Extend" 25 | } 26 | 27 | fn ui( 28 | &mut self, 29 | ui: &mut egui::Ui, 30 | app: &mut App, 31 | _gui: &mut crate::gui::Gui, 32 | _lua: &Lua, 33 | _font_size: u16, 34 | _line_spacing: u16, 35 | ) -> bool { 36 | ui.horizontal(|ui| { 37 | ui.label("Begin"); 38 | ui.add(DragValue::new(&mut self.begin).range(0..=self.end.saturating_sub(1))); 39 | if ui 40 | .add_enabled( 41 | self.begin != app.edit_state.cursor, 42 | Button::new("From cursor"), 43 | ) 44 | .clicked() 45 | { 46 | self.begin = app.edit_state.cursor; 47 | } 48 | }); 49 | ui.horizontal(|ui| { 50 | ui.label("End"); 51 | ui.add(DragValue::new(&mut self.end)); 52 | if ui 53 | .add_enabled( 54 | self.end != app.edit_state.cursor, 55 | Button::new("From cursor"), 56 | ) 57 | .clicked() 58 | { 59 | self.end = app.edit_state.cursor; 60 | } 61 | }); 62 | let new_len = (self.end + 1) - self.begin; 63 | let mut text = egui::RichText::new(format!("New length: {new_len}")); 64 | match new_len.cmp(&app.data.orig_data_len) { 65 | std::cmp::Ordering::Less => text = text.color(egui::Color32::RED), 66 | std::cmp::Ordering::Equal => {} 67 | std::cmp::Ordering::Greater => text = text.color(egui::Color32::YELLOW), 68 | } 69 | ui.label(text); 70 | if let Some(sel) = app.hex_ui.selection() { 71 | if ui 72 | .add_enabled( 73 | !(sel.begin == self.begin && sel.end == self.end), 74 | Button::new("From selection"), 75 | ) 76 | .clicked() 77 | { 78 | self.begin = sel.begin; 79 | self.end = sel.end; 80 | } 81 | } else { 82 | ui.add_enabled(false, Button::new("From selection")); 83 | } 84 | ui.separator(); 85 | let text = egui::RichText::new("⚠ Truncate/Extend ⚠").color(egui::Color32::RED); 86 | let mut retain = true; 87 | ui.horizontal(|ui| { 88 | if ui 89 | .button(text) 90 | .on_hover_text("This will change the length of the data") 91 | .clicked() 92 | { 93 | app.data.resize(self.end + 1, 0); 94 | app.data.drain(0..self.begin); 95 | app.hex_ui.clear_selections(); 96 | app.data.dirty_region = Some(Region { 97 | begin: 0, 98 | end: app.data.len(), 99 | }); 100 | } 101 | if ui.button("Close").clicked() { 102 | retain = false; 103 | } 104 | }); 105 | retain 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/gui/windows/script_manager.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::{ 4 | app::App, 5 | gui::Gui, 6 | meta::{ScriptKey, ScriptMap}, 7 | scripting::exec_lua, 8 | shell::msg_if_fail, 9 | }, 10 | egui_code_editor::{CodeEditor, Syntax}, 11 | mlua::Lua, 12 | }; 13 | 14 | #[derive(Default)] 15 | pub struct ScriptManagerWindow { 16 | pub open: WindowOpen, 17 | selected: Option, 18 | } 19 | 20 | impl super::Window for ScriptManagerWindow { 21 | fn ui( 22 | &mut self, 23 | WinCtx { 24 | ui, 25 | gui, 26 | app, 27 | lua, 28 | font_size, 29 | line_spacing, 30 | .. 31 | }: WinCtx, 32 | ) { 33 | let mut scripts = std::mem::take(&mut app.meta_state.meta.scripts); 34 | scripts.retain(|key, script| { 35 | let mut retain = true; 36 | ui.horizontal(|ui| { 37 | if app.meta_state.meta.onload_script == Some(key) { 38 | ui.label("⚡").on_hover_text("This script executes on document load"); 39 | } 40 | if ui.selectable_label(self.selected == Some(key), &script.name).clicked() { 41 | self.selected = Some(key); 42 | } 43 | if ui.button("⚡ Execute").clicked() { 44 | let result = exec_lua( 45 | lua, 46 | &script.content, 47 | app, 48 | gui, 49 | "", 50 | Some(key), 51 | font_size, 52 | line_spacing, 53 | ); 54 | msg_if_fail(result, "Failed to execute script", &mut gui.msg_dialog); 55 | } 56 | if ui.button("Delete").clicked() { 57 | retain = false; 58 | } 59 | }); 60 | retain 61 | }); 62 | if scripts.is_empty() { 63 | ui.label("There are no saved scripts."); 64 | } 65 | if ui.link("Open lua editor").clicked() { 66 | gui.win.lua_editor.open.set(true); 67 | } 68 | ui.separator(); 69 | self.selected_script_ui(ui, gui, app, lua, &mut scripts, font_size, line_spacing); 70 | std::mem::swap(&mut app.meta_state.meta.scripts, &mut scripts); 71 | } 72 | 73 | fn title(&self) -> &str { 74 | "Script manager" 75 | } 76 | } 77 | 78 | impl ScriptManagerWindow { 79 | fn selected_script_ui( 80 | &mut self, 81 | ui: &mut egui::Ui, 82 | gui: &mut Gui, 83 | app: &mut App, 84 | lua: &Lua, 85 | scripts: &mut ScriptMap, 86 | font_size: u16, 87 | line_spacing: u16, 88 | ) { 89 | let Some(key) = self.selected else { 90 | return; 91 | }; 92 | let Some(scr) = scripts.get_mut(key) else { 93 | self.selected = None; 94 | return; 95 | }; 96 | ui.label("Description"); 97 | ui.text_edit_multiline(&mut scr.desc); 98 | ui.label("Code"); 99 | egui::ScrollArea::vertical().show(ui, |ui| { 100 | CodeEditor::default().with_syntax(Syntax::lua()).show(ui, &mut scr.content); 101 | }); 102 | if ui.button("⚡ Execute").clicked() { 103 | let result = exec_lua( 104 | lua, 105 | &scr.content, 106 | app, 107 | gui, 108 | "", 109 | Some(key), 110 | font_size, 111 | line_spacing, 112 | ); 113 | msg_if_fail(result, "Failed to execute script", &mut gui.msg_dialog); 114 | } 115 | if ui.button("⚡ Set as onload script").clicked() { 116 | app.meta_state.meta.onload_script = Some(key); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/gui/windows/meta_diff.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::{ 4 | layout::Layout, 5 | meta::{ 6 | LayoutKey, NamedRegion, NamedView, PerspectiveKey, RegionKey, ViewKey, 7 | perspective::Perspective, 8 | }, 9 | }, 10 | itertools::{EitherOrBoth, Itertools as _}, 11 | slotmap::SlotMap, 12 | std::fmt::Debug, 13 | }; 14 | 15 | #[derive(Default)] 16 | pub struct MetaDiffWindow { 17 | pub open: WindowOpen, 18 | } 19 | impl super::Window for MetaDiffWindow { 20 | fn ui(&mut self, WinCtx { ui, app, .. }: WinCtx) { 21 | let this = &mut app.meta_state.meta; 22 | let clean = &app.meta_state.clean_meta; 23 | ui.heading("Regions"); 24 | diff_slotmap(ui, &mut this.low.regions, &clean.low.regions); 25 | ui.heading("Perspectives"); 26 | diff_slotmap(ui, &mut this.low.perspectives, &clean.low.perspectives); 27 | ui.heading("Views"); 28 | diff_slotmap(ui, &mut this.views, &clean.views); 29 | ui.heading("Layouts"); 30 | diff_slotmap(ui, &mut this.layouts, &clean.layouts); 31 | } 32 | 33 | fn title(&self) -> &str { 34 | "Diff against clean meta" 35 | } 36 | } 37 | 38 | trait SlotmapDiffItem: PartialEq + Eq + Clone + Debug { 39 | type Key: slotmap::Key; 40 | type SortKey: Ord; 41 | fn label(&self) -> &str; 42 | fn sort_key(&self) -> Self::SortKey; 43 | } 44 | 45 | impl SlotmapDiffItem for NamedRegion { 46 | type Key = RegionKey; 47 | 48 | fn label(&self) -> &str { 49 | &self.name 50 | } 51 | 52 | type SortKey = usize; 53 | 54 | fn sort_key(&self) -> Self::SortKey { 55 | self.region.begin 56 | } 57 | } 58 | 59 | impl SlotmapDiffItem for Perspective { 60 | type Key = PerspectiveKey; 61 | 62 | type SortKey = String; 63 | 64 | fn label(&self) -> &str { 65 | &self.name 66 | } 67 | 68 | fn sort_key(&self) -> Self::SortKey { 69 | self.name.clone() 70 | } 71 | } 72 | 73 | impl SlotmapDiffItem for NamedView { 74 | type Key = ViewKey; 75 | 76 | type SortKey = String; 77 | 78 | fn label(&self) -> &str { 79 | &self.name 80 | } 81 | 82 | fn sort_key(&self) -> Self::SortKey { 83 | self.name.clone() 84 | } 85 | } 86 | 87 | impl SlotmapDiffItem for Layout { 88 | type Key = LayoutKey; 89 | 90 | type SortKey = String; 91 | 92 | fn label(&self) -> &str { 93 | &self.name 94 | } 95 | 96 | fn sort_key(&self) -> Self::SortKey { 97 | self.name.to_owned() 98 | } 99 | } 100 | 101 | fn diff_slotmap( 102 | ui: &mut egui::Ui, 103 | this: &mut SlotMap, 104 | clean: &SlotMap, 105 | ) { 106 | let mut this_keys: Vec<_> = this.keys().collect(); 107 | this_keys.sort_by_key(|&k| this[k].sort_key()); 108 | let mut clean_keys: Vec<_> = clean.keys().collect(); 109 | clean_keys.sort_by_key(|&k| clean[k].sort_key()); 110 | let mut any_changed = false; 111 | for zip_item in this_keys.into_iter().zip_longest(clean_keys) { 112 | match zip_item { 113 | EitherOrBoth::Both(this_key, clean_key) => { 114 | if this_key != clean_key { 115 | ui.label("-"); 116 | any_changed = true; 117 | continue; 118 | } 119 | let this_item = &this[this_key]; 120 | let clean_item = &clean[clean_key]; 121 | if this_item != clean_item { 122 | any_changed = true; 123 | ui.label(format!( 124 | "{}: {:?}\n=>\n{:?}", 125 | this_item.label(), 126 | this_item, 127 | clean_item 128 | )); 129 | } 130 | } 131 | EitherOrBoth::Left(this_key) => { 132 | any_changed = true; 133 | ui.label(format!("New {}", this[this_key].label())); 134 | } 135 | EitherOrBoth::Right(clean_key) => { 136 | any_changed = true; 137 | ui.label(format!("Deleted {}", clean[clean_key].label())); 138 | } 139 | } 140 | } 141 | if any_changed { 142 | if ui.button("Restore").clicked() { 143 | this.clone_from(clean); 144 | } 145 | } else { 146 | ui.label("No changes"); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{args::SourceArgs, result_ext::AnyhowConv as _}, 3 | anyhow::Context as _, 4 | directories::ProjectDirs, 5 | egui_fontcfg::CustomFontPaths, 6 | recently_used_list::RecentlyUsedList, 7 | serde::{Deserialize, Serialize}, 8 | std::{ 9 | collections::{BTreeMap, HashMap}, 10 | path::PathBuf, 11 | }, 12 | }; 13 | 14 | #[derive(Serialize, Deserialize)] 15 | pub struct Config { 16 | pub recent: RecentlyUsedList, 17 | pub style: Style, 18 | /// filepath->meta associations 19 | #[serde(default)] 20 | pub meta_assocs: MetaAssocs, 21 | #[serde(default = "default_vsync")] 22 | pub vsync: bool, 23 | #[serde(default)] 24 | pub fps_limit: u32, 25 | #[serde(default)] 26 | pub pinned_dirs: Vec, 27 | #[serde(default)] 28 | pub custom_font_paths: CustomFontPaths, 29 | #[serde(default)] 30 | pub font_families: BTreeMap>, 31 | } 32 | 33 | #[derive(Serialize, Deserialize)] 34 | pub struct PinnedDir { 35 | pub path: PathBuf, 36 | pub label: String, 37 | } 38 | 39 | const fn default_vsync() -> bool { 40 | true 41 | } 42 | 43 | pub type MetaAssocs = HashMap; 44 | 45 | #[derive(Serialize, Deserialize, Default)] 46 | pub struct Style { 47 | pub font_sizes: FontSizes, 48 | } 49 | 50 | #[derive(Serialize, Deserialize)] 51 | pub struct FontSizes { 52 | pub heading: u8, 53 | pub body: u8, 54 | pub monospace: u8, 55 | pub button: u8, 56 | pub small: u8, 57 | } 58 | 59 | impl Default for FontSizes { 60 | fn default() -> Self { 61 | Self { 62 | small: 10, 63 | body: 14, 64 | button: 14, 65 | heading: 16, 66 | monospace: 14, 67 | } 68 | } 69 | } 70 | 71 | const DEFAULT_RECENT_CAPACITY: usize = 16; 72 | 73 | impl Default for Config { 74 | fn default() -> Self { 75 | let mut recent = RecentlyUsedList::default(); 76 | recent.set_capacity(DEFAULT_RECENT_CAPACITY); 77 | Self { 78 | recent, 79 | style: Style::default(), 80 | meta_assocs: HashMap::default(), 81 | fps_limit: 0, 82 | vsync: default_vsync(), 83 | pinned_dirs: Vec::new(), 84 | custom_font_paths: Default::default(), 85 | font_families: Default::default(), 86 | } 87 | } 88 | } 89 | 90 | pub struct LoadedConfig { 91 | pub config: Config, 92 | /// If `Some`, saving this config file will overwrite an old one that couldn't be loaded 93 | pub old_config_err: Option, 94 | } 95 | 96 | impl Config { 97 | pub fn load_or_default() -> anyhow::Result { 98 | let proj_dirs = project_dirs().context("Failed to get project dirs")?; 99 | let cfg_dir = proj_dirs.config_dir(); 100 | if !cfg_dir.exists() { 101 | std::fs::create_dir_all(cfg_dir)?; 102 | } 103 | let cfg_file = cfg_dir.join(FILENAME); 104 | if !cfg_file.exists() { 105 | Ok(LoadedConfig { 106 | config: Self::default(), 107 | old_config_err: None, 108 | }) 109 | } else { 110 | let result = try { 111 | let cfg_bytes = std::fs::read(cfg_file).how()?; 112 | rmp_serde::from_slice(&cfg_bytes).how()? 113 | }; 114 | match result { 115 | Ok(cfg) => Ok(LoadedConfig { 116 | config: cfg, 117 | old_config_err: None, 118 | }), 119 | Err(e) => Ok(LoadedConfig { 120 | config: Self::default(), 121 | old_config_err: Some(e), 122 | }), 123 | } 124 | } 125 | } 126 | pub fn save(&self) -> anyhow::Result<()> { 127 | let bytes = rmp_serde::to_vec(self)?; 128 | let proj_dirs = project_dirs().context("Failed to get project dirs")?; 129 | let cfg_dir = proj_dirs.config_dir(); 130 | std::fs::write(cfg_dir.join(FILENAME), bytes)?; 131 | Ok(()) 132 | } 133 | } 134 | 135 | pub fn project_dirs() -> Option { 136 | ProjectDirs::from("", "crumblingstatue", "hexerator") 137 | } 138 | 139 | pub trait ProjectDirsExt { 140 | fn color_theme_path(&self) -> PathBuf; 141 | } 142 | 143 | impl ProjectDirsExt for ProjectDirs { 144 | fn color_theme_path(&self) -> PathBuf { 145 | self.config_dir().join("egui_colors_theme.pal") 146 | } 147 | } 148 | 149 | const FILENAME: &str = "hexerator.cfg"; 150 | -------------------------------------------------------------------------------- /src/gui/selection_menu.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | damage_region::DamageRegion, 5 | gui::{ 6 | Gui, 7 | dialogs::{LuaFillDialog, PatternFillDialog, X86AsmDialog}, 8 | file_ops::FileOps, 9 | message_dialog::MessageDialog, 10 | windows::RegionsWindow, 11 | }, 12 | shell::msg_fail, 13 | }, 14 | constcat::concat, 15 | egui::Button, 16 | egui_phosphor::regular as ic, 17 | rand::RngCore as _, 18 | std::fmt::Write as _, 19 | }; 20 | 21 | const L_UNSELECT: &str = concat!(ic::SELECTION_SLASH, " Unselect"); 22 | const L_ZERO_FILL: &str = concat!(ic::NUMBER_SQUARE_ZERO, " Zero fill"); 23 | const L_PATTERN_FILL: &str = concat!(ic::BINARY, " Pattern fill..."); 24 | const L_LUA_FILL: &str = concat!(ic::MOON, " Lua fill..."); 25 | const L_RANDOM_FILL: &str = concat!(ic::SHUFFLE, " Random fill"); 26 | const L_COPY_AS_HEX_TEXT: &str = concat!(ic::COPY, " Copy as hex text"); 27 | const L_COPY_AS_UTF8: &str = concat!(ic::COPY, " Copy as utf-8 text"); 28 | const L_ADD_AS_REGION: &str = concat!(ic::RULER, " Add as region"); 29 | const L_SAVE_TO_FILE: &str = concat!(ic::FLOPPY_DISK, " Save to file"); 30 | const L_X86_ASM: &str = concat!(ic::PIPE_WRENCH, " X86 asm"); 31 | 32 | /// Returns whether anything was clicked 33 | pub fn selection_menu( 34 | title: &str, 35 | ui: &mut egui::Ui, 36 | app: &mut App, 37 | gui_dialogs: &mut crate::gui::Dialogs, 38 | gui_msg_dialog: &mut MessageDialog, 39 | gui_regions_window: &mut RegionsWindow, 40 | sel: crate::meta::region::Region, 41 | file_ops: &mut FileOps, 42 | ) -> bool { 43 | let mut clicked = false; 44 | ui.menu_button(title, |ui| { 45 | if ui.add(Button::new(L_UNSELECT).shortcut_text("Esc")).clicked() { 46 | app.hex_ui.clear_selections(); 47 | 48 | clicked = true; 49 | } 50 | if ui.add(Button::new(L_ZERO_FILL).shortcut_text("Del")).clicked() { 51 | app.data.zero_fill_region(sel); 52 | 53 | clicked = true; 54 | } 55 | if ui.button(L_PATTERN_FILL).clicked() { 56 | Gui::add_dialog(gui_dialogs, PatternFillDialog::default()); 57 | 58 | clicked = true; 59 | } 60 | if ui.button(L_LUA_FILL).clicked() { 61 | Gui::add_dialog(gui_dialogs, LuaFillDialog::default()); 62 | 63 | clicked = true; 64 | } 65 | if ui.button(L_RANDOM_FILL).clicked() { 66 | for region in app.hex_ui.selected_regions() { 67 | if let Some(data) = app.data.get_mut(region.to_range()) { 68 | rand::rng().fill_bytes(data); 69 | app.data.widen_dirty_region(DamageRegion::RangeInclusive(region.to_range())); 70 | } 71 | } 72 | 73 | clicked = true; 74 | } 75 | if ui.button(L_COPY_AS_HEX_TEXT).clicked() { 76 | let mut s = String::new(); 77 | let result = try { 78 | for &byte in &app.data[sel.begin..=sel.end] { 79 | write!(&mut s, "{byte:02x} ")?; 80 | } 81 | }; 82 | match result { 83 | Ok(()) => { 84 | crate::app::set_clipboard_string( 85 | &mut app.clipboard, 86 | gui_msg_dialog, 87 | s.trim_end(), 88 | ); 89 | } 90 | Err(e) => { 91 | msg_fail(&e, "Failed to copy as hex text", gui_msg_dialog); 92 | } 93 | } 94 | 95 | clicked = true; 96 | } 97 | if ui.button(L_COPY_AS_UTF8).clicked() { 98 | let s = String::from_utf8_lossy(&app.data[sel.begin..=sel.end]); 99 | crate::app::set_clipboard_string(&mut app.clipboard, gui_msg_dialog, &s); 100 | 101 | clicked = true; 102 | } 103 | if ui.button(L_ADD_AS_REGION).clicked() { 104 | crate::gui::ops::add_region_from_selection( 105 | sel, 106 | &mut app.meta_state, 107 | gui_regions_window, 108 | ); 109 | 110 | clicked = true; 111 | } 112 | if ui.button(L_SAVE_TO_FILE).clicked() { 113 | file_ops.save_selection_to_file(sel); 114 | 115 | clicked = true; 116 | } 117 | if ui.button(L_X86_ASM).clicked() { 118 | Gui::add_dialog(gui_dialogs, X86AsmDialog::new()); 119 | 120 | clicked = true; 121 | } 122 | }); 123 | clicked 124 | } 125 | -------------------------------------------------------------------------------- /src/gui/top_menu/view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{app::App, gui::Gui, hex_ui::Ruler, meta::LayoutMapExt as _}, 3 | constcat::concat, 4 | egui::{ 5 | Button, 6 | color_picker::{Alpha, color_picker_color32}, 7 | containers::menu::{MenuConfig, SubMenuButton}, 8 | }, 9 | egui_phosphor::regular as ic, 10 | }; 11 | 12 | const L_LAYOUT: &str = concat!(ic::LAYOUT, " Layout"); 13 | const L_RULER: &str = concat!(ic::RULER, " Ruler"); 14 | const L_LAYOUTS: &str = concat!(ic::LAYOUT, " Layouts..."); 15 | const L_FOCUS_PREV: &str = concat!(ic::ARROW_FAT_LEFT, " Focus previous"); 16 | const L_FOCUS_NEXT: &str = concat!(ic::ARROW_FAT_RIGHT, " Focus next"); 17 | const L_VIEWS: &str = concat!(ic::EYE, " Views..."); 18 | 19 | pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App) { 20 | if ui.add(Button::new(L_VIEWS).shortcut_text("F6")).clicked() { 21 | gui.win.views.open.toggle(); 22 | } 23 | if ui.add(Button::new(L_FOCUS_PREV).shortcut_text("Shift+Tab")).clicked() { 24 | app.focus_prev_view_in_layout(); 25 | } 26 | if ui.add(Button::new(L_FOCUS_NEXT).shortcut_text("Tab")).clicked() { 27 | app.focus_next_view_in_layout(); 28 | } 29 | ui.menu_button(L_RULER, |ui| match app.focused_view_mut() { 30 | Some((key, _view)) => match app.hex_ui.rulers.get_mut(&key) { 31 | Some(ruler) => { 32 | if ui.button("Remove").clicked() { 33 | app.hex_ui.rulers.remove(&key); 34 | return; 35 | } 36 | ruler.color.with_as_egui_mut(|c| { 37 | // Customized color SubMenuButton (taken from the egui demo) 38 | let is_bright = c.intensity() > 0.5; 39 | let text_color = if is_bright { 40 | egui::Color32::BLACK 41 | } else { 42 | egui::Color32::WHITE 43 | }; 44 | let mut color_button = 45 | SubMenuButton::new(egui::RichText::new("Color").color(text_color)).config( 46 | MenuConfig::new() 47 | .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside), 48 | ); 49 | color_button.button = color_button.button.fill(*c); 50 | color_button.ui(ui, |ui| { 51 | ui.spacing_mut().slider_width = 200.0; 52 | color_picker_color32(ui, c, Alpha::Opaque); 53 | }); 54 | }); 55 | ui.label("Frequency"); 56 | ui.add(egui::DragValue::new(&mut ruler.freq)); 57 | ui.label("Horizontal offset"); 58 | ui.add(egui::DragValue::new(&mut ruler.hoffset)); 59 | ui.menu_button("struct", |ui| { 60 | for (i, struct_) in app.meta_state.meta.structs.iter().enumerate() { 61 | if ui.selectable_label(ruler.struct_idx == Some(i), &struct_.name).clicked() 62 | { 63 | ruler.struct_idx = Some(i); 64 | } 65 | } 66 | ui.separator(); 67 | if ui.button("Unassociate").clicked() { 68 | ruler.struct_idx = None; 69 | } 70 | }); 71 | } 72 | None => { 73 | if ui.button("Add ruler for current view").clicked() { 74 | app.hex_ui.rulers.insert(key, Ruler::default()); 75 | } 76 | } 77 | }, 78 | None => { 79 | ui.label(""); 80 | } 81 | }); 82 | ui.separator(); 83 | ui.menu_button(L_LAYOUT, |ui| { 84 | if ui.add(Button::new(L_LAYOUTS).shortcut_text("F5")).clicked() { 85 | gui.win.layouts.open.toggle(); 86 | } 87 | if ui.button("➕ Add new").clicked() { 88 | app.hex_ui.current_layout = app.meta_state.meta.layouts.add_new_default(); 89 | gui.win.layouts.open.set(true); 90 | } 91 | ui.separator(); 92 | for (k, v) in &app.meta_state.meta.layouts { 93 | if ui 94 | .selectable_label( 95 | app.hex_ui.current_layout == k, 96 | [ic::LAYOUT, " ", v.name.as_str()].concat(), 97 | ) 98 | .clicked() 99 | { 100 | App::switch_layout(&mut app.hex_ui, &app.meta_state.meta, k); 101 | } 102 | } 103 | }); 104 | ui.checkbox( 105 | &mut app.preferences.col_change_lock_col, 106 | "Lock col on col change", 107 | ); 108 | ui.checkbox( 109 | &mut app.preferences.col_change_lock_row, 110 | "Lock row on col change", 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::parse_radix::parse_guess_radix, 3 | clap::Parser, 4 | serde::{Deserialize, Serialize}, 5 | std::path::PathBuf, 6 | }; 7 | 8 | /// Hexerator: Versatile hex editor 9 | #[derive(Parser, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] 10 | pub struct Args { 11 | /// Arguments relating to the source to open 12 | #[clap(flatten)] 13 | pub src: SourceArgs, 14 | /// Open most recently used file 15 | #[arg(long)] 16 | pub recent: bool, 17 | /// Load this metafile 18 | #[arg(long, value_name = "path")] 19 | pub meta: Option, 20 | /// Show version information and exit 21 | #[arg(long)] 22 | pub version: bool, 23 | /// Start with debug logging enabled 24 | #[arg(long)] 25 | pub debug: bool, 26 | /// Spawn and open memory of a command with arguments (must be last option) 27 | #[arg(long, value_name="command", allow_hyphen_values=true, num_args=1..)] 28 | pub spawn_command: Vec, 29 | #[arg(long, value_name = "name")] 30 | /// When spawning a command, open the process list with this filter, rather than selecting a pid 31 | pub look_for_proc: Option, 32 | /// Automatically reload the source for the current buffer in millisecond intervals (default:250) 33 | #[arg(long, value_name="interval", default_missing_value="250", num_args=0..=1)] 34 | pub autoreload: Option, 35 | /// Only autoreload the data visible in the current layout 36 | #[arg(long)] 37 | pub autoreload_only_visible: bool, 38 | /// Automatically save if there is an edited region in the file 39 | #[arg(long)] 40 | pub autosave: bool, 41 | /// Open this layout on startup instead of the default 42 | #[arg(long, value_name = "name")] 43 | pub layout: Option, 44 | /// Focus the first instance of this view on startup 45 | #[arg(long, value_name = "name")] 46 | pub view: Option, 47 | #[arg(long)] 48 | /// Load a dynamic library plugin at startup 49 | pub load_plugin: Vec, 50 | /// Allocate a new (zero-filled) buffer. Also creates the provided file argument if it doesn't exist. 51 | #[arg(long, value_name = "length")] 52 | pub new: Option, 53 | /// Diff against this file 54 | #[arg(long, value_name = "path", alias = "diff-with")] 55 | pub diff_against: Option, 56 | /// Set the initial column count of the default perspective 57 | #[arg(short = 'c', long = "col")] 58 | pub column_count: Option, 59 | } 60 | 61 | /// Arguments for opening a source (file/stream/process/etc) 62 | #[derive(Parser, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] 63 | pub struct SourceArgs { 64 | /// The file to read 65 | pub file: Option, 66 | /// Jump to offset on startup 67 | #[arg(short = 'j', long="jump", value_name="offset", value_parser = parse_guess_radix::)] 68 | pub jump: Option, 69 | /// Seek to offset, consider it beginning of the file in the editor 70 | #[arg(long, value_name="offset", value_parser = parse_guess_radix::)] 71 | pub hard_seek: Option, 72 | /// Read only this many bytes 73 | #[arg(long, value_name = "bytes", value_parser = parse_guess_radix::)] 74 | pub take: Option, 75 | /// Open file as read-only, without writing privileges 76 | #[arg(long)] 77 | pub read_only: bool, 78 | /// Specify source as a streaming source (for example, standard streams). 79 | /// Sets read-only attribute. 80 | #[arg(long)] 81 | pub stream: bool, 82 | /// The buffer size in bytes to use for reading when streaming 83 | #[arg(long)] 84 | #[serde(default)] 85 | pub stream_buffer_size: Option, 86 | /// Try to open the source using mmap rather than load into a buffer 87 | #[serde(default)] 88 | #[arg(long, value_name = "mode")] 89 | pub unsafe_mmap: Option, 90 | /// Assume the memory mapped file is of this length (might be needed for looking at block devices, etc.) 91 | #[serde(default)] 92 | #[arg(long, value_name = "len")] 93 | pub mmap_len: Option, 94 | } 95 | 96 | /// How the memory mapping should operate 97 | #[derive( 98 | Clone, 99 | Copy, 100 | clap::ValueEnum, 101 | Debug, 102 | Serialize, 103 | Deserialize, 104 | PartialEq, 105 | Eq, 106 | strum::IntoStaticStr, 107 | strum::EnumIter, 108 | Default, 109 | )] 110 | pub enum MmapMode { 111 | /// Read-only memory map. 112 | /// Note: Some features may not work, as Hexerator was designed for a mutable data buffer. 113 | #[default] 114 | Ro, 115 | /// Copy-on-write memory map. 116 | /// Changes are only visible locally. 117 | Cow, 118 | /// Mutable memory map. 119 | /// *WARNING*: Any edits will immediately take effect. THEY CANNOT BE UNDONE. 120 | DangerousMut, 121 | } 122 | -------------------------------------------------------------------------------- /src/gui/top_menu/meta.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::App, 4 | gui::{Gui, egui_ui_ext::EguiResponseExt as _}, 5 | shell::msg_if_fail, 6 | }, 7 | constcat::concat, 8 | egui::Button, 9 | egui_phosphor::regular as ic, 10 | }; 11 | 12 | const L_PERSPECTIVES: &str = concat!(ic::PERSPECTIVE, " Perspectives..."); 13 | const L_REGIONS: &str = concat!(ic::RULER, " Regions..."); 14 | const L_BOOKMARKS: &str = concat!(ic::BOOKMARK, " Bookmarks..."); 15 | const L_VARIABLES: &str = concat!(ic::CALCULATOR, " Variables..."); 16 | const L_STRUCTS: &str = concat!(ic::BLUEPRINT, " Structs..."); 17 | const L_RELOAD: &str = concat!(ic::ARROW_COUNTER_CLOCKWISE, " Reload"); 18 | const L_LOAD_FROM_FILE: &str = concat!(ic::FOLDER_OPEN, " Load from file..."); 19 | const L_LOAD_FROM_BACKUP: &str = concat!(ic::CLOUD_ARROW_DOWN, " Load from temp backup"); 20 | const L_CLEAR: &str = concat!(ic::BROOM, " Clear"); 21 | const L_DIFF_WITH_CLEAN_META: &str = concat!(ic::GIT_DIFF, " Diff with clean meta"); 22 | const L_SAVE: &str = concat!(ic::FLOPPY_DISK, " Save"); 23 | const L_SAVE_AS: &str = concat!(ic::FLOPPY_DISK_BACK, " Save as..."); 24 | const L_ASSOCIATE_WITH_CURRENT: &str = concat!(ic::FLOW_ARROW, " Associate with current file"); 25 | 26 | pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, font_size: u16, line_spacing: u16) { 27 | if ui.add(Button::new(L_PERSPECTIVES).shortcut_text("F7")).clicked() { 28 | gui.win.perspectives.open.toggle(); 29 | } 30 | if ui.add(Button::new(L_REGIONS).shortcut_text("F8")).clicked() { 31 | gui.win.regions.open.toggle(); 32 | } 33 | if ui.add(Button::new(L_BOOKMARKS).shortcut_text("F9")).clicked() { 34 | gui.win.bookmarks.open.toggle(); 35 | } 36 | if ui.add(Button::new(L_VARIABLES).shortcut_text("F10")).clicked() { 37 | gui.win.vars.open.toggle(); 38 | } 39 | if ui.add(Button::new(L_STRUCTS).shortcut_text("F11")).clicked() { 40 | gui.win.structs.open.toggle(); 41 | } 42 | ui.separator(); 43 | if ui 44 | .button(L_DIFF_WITH_CLEAN_META) 45 | .on_hover_text("See and manage changes to metafile") 46 | .clicked() 47 | { 48 | gui.win.meta_diff.open.toggle(); 49 | } 50 | ui.separator(); 51 | if ui 52 | .add_enabled( 53 | !app.meta_state.current_meta_path.as_os_str().is_empty(), 54 | Button::new(L_RELOAD), 55 | ) 56 | .on_hover_text_deferred(|| { 57 | format!("Reload from {}", app.meta_state.current_meta_path.display()) 58 | }) 59 | .clicked() 60 | { 61 | msg_if_fail( 62 | app.consume_meta_from_file(app.meta_state.current_meta_path.clone(), false), 63 | "Failed to load metafile", 64 | &mut gui.msg_dialog, 65 | ); 66 | } 67 | if ui.button(L_LOAD_FROM_FILE).clicked() { 68 | gui.fileops.load_meta_file(); 69 | } 70 | if ui 71 | .button(L_LOAD_FROM_BACKUP) 72 | .on_hover_text("Load from temporary backup (auto generated on save/exit)") 73 | .clicked() 74 | { 75 | msg_if_fail( 76 | app.consume_meta_from_file(crate::app::temp_metafile_backup_path(), true), 77 | "Failed to load temp metafile", 78 | &mut gui.msg_dialog, 79 | ); 80 | } 81 | if ui 82 | .button(L_CLEAR) 83 | .on_hover_text("Replace current meta with default one") 84 | .clicked() 85 | { 86 | app.clear_meta(font_size, line_spacing); 87 | } 88 | ui.separator(); 89 | if ui 90 | .add_enabled( 91 | !app.meta_state.current_meta_path.as_os_str().is_empty(), 92 | Button::new(L_SAVE).shortcut_text("Ctrl+M"), 93 | ) 94 | .on_hover_text_deferred(|| { 95 | format!("Save to {}", app.meta_state.current_meta_path.display()) 96 | }) 97 | .clicked() 98 | { 99 | msg_if_fail( 100 | app.save_meta(), 101 | "Failed to save metafile", 102 | &mut gui.msg_dialog, 103 | ); 104 | } 105 | if ui.button(L_SAVE_AS).clicked() { 106 | gui.fileops.save_metafile_as(); 107 | } 108 | ui.separator(); 109 | match ( 110 | app.source_file(), 111 | app.meta_state.current_meta_path.as_os_str().is_empty(), 112 | ) { 113 | (Some(src), false) => { 114 | if ui 115 | .button(L_ASSOCIATE_WITH_CURRENT) 116 | .on_hover_text("When you open this file, it will use this metafile") 117 | .clicked() 118 | { 119 | app.cfg 120 | .meta_assocs 121 | .insert(src.to_owned(), app.meta_state.current_meta_path.clone()); 122 | } 123 | } 124 | _ => { 125 | ui.add_enabled(false, Button::new(L_ASSOCIATE_WITH_CURRENT)) 126 | .on_disabled_hover_text("Both file and metafile need to have a path"); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/struct_meta_item.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Clone)] 4 | pub struct StructMetaItem { 5 | pub name: String, 6 | pub src: String, 7 | pub fields: Vec, 8 | } 9 | 10 | impl StructMetaItem { 11 | pub fn new(parsed: structparse::Struct, src: String) -> anyhow::Result { 12 | let fields: anyhow::Result> = 13 | parsed.fields.into_iter().map(try_resolve_field).collect(); 14 | Ok(Self { 15 | name: parsed.name.to_string(), 16 | src, 17 | fields: fields?, 18 | }) 19 | } 20 | pub fn fields_with_offsets_mut(&mut self) -> impl Iterator { 21 | let mut offset = 0; 22 | let mut fields = self.fields.iter_mut(); 23 | std::iter::from_fn(move || { 24 | let field = fields.next()?; 25 | let ty_size = field.ty.size(); 26 | let item = (offset, &mut *field); 27 | offset += ty_size; 28 | Some(item) 29 | }) 30 | } 31 | } 32 | 33 | fn try_resolve_field(field: structparse::Field) -> anyhow::Result { 34 | Ok(StructField { 35 | name: field.name.to_string(), 36 | ty: try_resolve_ty(field.ty)?, 37 | }) 38 | } 39 | 40 | fn try_resolve_ty(ty: structparse::Ty) -> anyhow::Result { 41 | match ty { 42 | structparse::Ty::Ident(ident) => { 43 | let prim = match ident { 44 | "i8" => StructPrimitive::I8, 45 | "u8" => StructPrimitive::U8, 46 | "i16" => StructPrimitive::I16, 47 | "u16" => StructPrimitive::U16, 48 | "i32" => StructPrimitive::I32, 49 | "u32" => StructPrimitive::U32, 50 | "i64" => StructPrimitive::I64, 51 | "u64" => StructPrimitive::U64, 52 | "f32" => StructPrimitive::F32, 53 | "f64" => StructPrimitive::F64, 54 | _ => anyhow::bail!("Unknown type"), 55 | }; 56 | Ok(StructTy::Primitive { 57 | ty: prim, 58 | endian: Endian::Le, 59 | }) 60 | } 61 | structparse::Ty::Array(array) => Ok(StructTy::Array { 62 | item_ty: Box::new(try_resolve_ty(*array.ty)?), 63 | len: array.len.try_into()?, 64 | }), 65 | } 66 | } 67 | 68 | #[derive(Serialize, Deserialize, Clone)] 69 | pub struct StructField { 70 | pub name: String, 71 | pub ty: StructTy, 72 | } 73 | 74 | #[derive(Serialize, Deserialize, Clone, Copy)] 75 | pub enum Endian { 76 | Le, 77 | Be, 78 | } 79 | 80 | impl Endian { 81 | pub fn label(&self) -> &'static str { 82 | match self { 83 | Self::Le => "le", 84 | Self::Be => "be", 85 | } 86 | } 87 | 88 | pub(crate) fn toggle(&mut self) { 89 | *self = match self { 90 | Self::Le => Self::Be, 91 | Self::Be => Self::Le, 92 | } 93 | } 94 | } 95 | 96 | #[derive(Serialize, Deserialize, Clone)] 97 | pub enum StructTy { 98 | Primitive { ty: StructPrimitive, endian: Endian }, 99 | Array { item_ty: Box, len: usize }, 100 | } 101 | 102 | #[derive(Serialize, Deserialize, Clone)] 103 | pub enum StructPrimitive { 104 | I8, 105 | U8, 106 | I16, 107 | U16, 108 | I32, 109 | U32, 110 | I64, 111 | U64, 112 | F32, 113 | F64, 114 | } 115 | 116 | impl StructPrimitive { 117 | fn label(&self) -> &'static str { 118 | match self { 119 | Self::I8 => "i8", 120 | Self::U8 => "u8", 121 | Self::I16 => "i16", 122 | Self::U16 => "u16", 123 | Self::I32 => "i32", 124 | Self::U32 => "u32", 125 | Self::I64 => "i64", 126 | Self::U64 => "u64", 127 | Self::F32 => "f32", 128 | Self::F64 => "f64", 129 | } 130 | } 131 | } 132 | 133 | impl StructTy { 134 | pub fn size(&self) -> usize { 135 | match self { 136 | Self::Primitive { ty, .. } => match ty { 137 | StructPrimitive::I8 | StructPrimitive::U8 => 1, 138 | StructPrimitive::I16 | StructPrimitive::U16 => 2, 139 | StructPrimitive::I32 | StructPrimitive::U32 | StructPrimitive::F32 => 4, 140 | StructPrimitive::I64 | StructPrimitive::U64 | StructPrimitive::F64 => 8, 141 | }, 142 | Self::Array { item_ty, len } => item_ty.size() * *len, 143 | } 144 | } 145 | pub fn endian_mut(&mut self) -> &mut Endian { 146 | match self { 147 | Self::Primitive { endian, .. } => endian, 148 | Self::Array { item_ty, .. } => item_ty.endian_mut(), 149 | } 150 | } 151 | } 152 | 153 | impl std::fmt::Display for StructTy { 154 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 155 | match self { 156 | Self::Primitive { ty, endian } => { 157 | let ty = ty.label(); 158 | let endian = endian.label(); 159 | write!(f, "{ty}-{endian}") 160 | } 161 | Self::Array { item_ty, len } => { 162 | write!(f, "[{item_ty}; {len}]") 163 | } 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/gui/windows/lua_console.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::{meta::ScriptKey, scripting::exec_lua, shell::msg_if_fail}, 4 | std::{collections::HashMap, fmt::Write as _}, 5 | }; 6 | 7 | type MsgBuf = Vec; 8 | type MsgBufMap = HashMap; 9 | 10 | #[derive(Default)] 11 | pub struct LuaConsoleWindow { 12 | pub open: WindowOpen, 13 | pub msg_bufs: MsgBufMap, 14 | pub eval_buf: String, 15 | pub active_msg_buf: Option, 16 | pub default_msg_buf: MsgBuf, 17 | } 18 | 19 | impl LuaConsoleWindow { 20 | fn msg_buf(&mut self) -> &mut MsgBuf { 21 | match self.active_msg_buf { 22 | Some(key) => self.msg_bufs.get_mut(&key).unwrap_or(&mut self.default_msg_buf), 23 | None => &mut self.default_msg_buf, 24 | } 25 | } 26 | pub fn msg_buf_for_key(&mut self, key: Option) -> &mut MsgBuf { 27 | match key { 28 | Some(key) => self.msg_bufs.entry(key).or_default(), 29 | None => &mut self.default_msg_buf, 30 | } 31 | } 32 | } 33 | 34 | pub enum ConMsg { 35 | Plain(String), 36 | OffsetLink { 37 | text: String, 38 | offset: usize, 39 | }, 40 | RangeLink { 41 | text: String, 42 | start: usize, 43 | end: usize, 44 | }, 45 | } 46 | 47 | impl super::Window for LuaConsoleWindow { 48 | fn ui( 49 | &mut self, 50 | WinCtx { 51 | ui, 52 | gui, 53 | app, 54 | lua, 55 | font_size, 56 | line_spacing, 57 | .. 58 | }: WinCtx, 59 | ) { 60 | ui.horizontal(|ui| { 61 | if ui.selectable_label(self.active_msg_buf.is_none(), "Default").clicked() { 62 | self.active_msg_buf = None; 63 | } 64 | for k in self.msg_bufs.keys() { 65 | if ui 66 | .selectable_label( 67 | self.active_msg_buf == Some(*k), 68 | &app.meta_state.meta.scripts[*k].name, 69 | ) 70 | .clicked() 71 | { 72 | self.active_msg_buf = Some(*k); 73 | } 74 | } 75 | }); 76 | ui.separator(); 77 | ui.horizontal(|ui| { 78 | let re = ui.text_edit_singleline(&mut self.eval_buf); 79 | if ui.button("x").on_hover_text("Clear input").clicked() { 80 | self.eval_buf.clear(); 81 | } 82 | if ui.button("Eval").clicked() 83 | || (ui.input(|inp| inp.key_pressed(egui::Key::Enter)) && re.lost_focus()) 84 | { 85 | let code = &self.eval_buf.clone(); 86 | if let Err(e) = exec_lua( 87 | lua, 88 | code, 89 | app, 90 | gui, 91 | "", 92 | self.active_msg_buf, 93 | font_size, 94 | line_spacing, 95 | ) { 96 | self.msg_buf().push(ConMsg::Plain(e.to_string())); 97 | } 98 | } 99 | if ui.button("Clear log").clicked() { 100 | self.msg_buf().clear(); 101 | } 102 | if ui.button("Copy to clipboard").clicked() { 103 | let mut buf = String::new(); 104 | for msg in self.msg_buf() { 105 | match msg { 106 | ConMsg::Plain(s) => { 107 | buf.push_str(s); 108 | buf.push('\n'); 109 | } 110 | ConMsg::OffsetLink { text, offset } => { 111 | let _ = writeln!(&mut buf, "{offset}: {text}"); 112 | } 113 | ConMsg::RangeLink { text, start, end } => { 114 | let _ = writeln!(&mut buf, "{start}..={end}: {text}"); 115 | } 116 | } 117 | } 118 | msg_if_fail( 119 | app.clipboard.set_text(buf), 120 | "Failed to copy clipboard text", 121 | &mut gui.msg_dialog, 122 | ); 123 | } 124 | }); 125 | ui.separator(); 126 | egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| { 127 | for msg in &*self.msg_buf() { 128 | match msg { 129 | ConMsg::Plain(text) => { 130 | ui.label(text); 131 | } 132 | ConMsg::OffsetLink { text, offset } => { 133 | if ui.link(text).clicked() { 134 | app.search_focus(*offset); 135 | } 136 | } 137 | ConMsg::RangeLink { text, start, end } => { 138 | if ui.link(text).clicked() { 139 | app.hex_ui.select_a = Some(*start); 140 | app.hex_ui.select_b = Some(*end); 141 | app.search_focus(*start); 142 | } 143 | } 144 | } 145 | } 146 | }); 147 | } 148 | 149 | fn title(&self) -> &str { 150 | "Lua quick eval" 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/gui/top_menu/file.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::{App, set_clipboard_string}, 4 | gui::{Gui, dialogs::AutoSaveReloadDialog}, 5 | shell::msg_if_fail, 6 | }, 7 | constcat::concat, 8 | egui::Button, 9 | egui_phosphor::regular as ic, 10 | }; 11 | 12 | const L_LOPEN: &str = concat!(ic::FOLDER_OPEN, " Open..."); 13 | const L_OPEN_PROCESS: &str = concat!(ic::CPU, " Open process..."); 14 | const L_OPEN_PREVIOUS: &str = concat!(ic::ARROWS_LEFT_RIGHT, " Open previous"); 15 | const L_SAVE: &str = concat!(ic::FLOPPY_DISK, " Save"); 16 | const L_SAVE_AS: &str = concat!(ic::FLOPPY_DISK_BACK, " Save as..."); 17 | const L_RELOAD: &str = concat!(ic::ARROW_COUNTER_CLOCKWISE, " Reload"); 18 | const L_RECENT: &str = concat!(ic::CLOCK_COUNTER_CLOCKWISE, " Recent"); 19 | const L_AUTO_SAVE_RELOAD: &str = concat!(ic::MAGNET, " Auto save/reload..."); 20 | const L_CREATE_BACKUP: &str = concat!(ic::CLOUD_ARROW_UP, " Create backup"); 21 | const L_RESTORE_BACKUP: &str = concat!(ic::CLOUD_ARROW_DOWN, " Restore backup"); 22 | const L_PREFERENCES: &str = concat!(ic::GEAR_SIX, " Preferences"); 23 | const L_CLOSE: &str = concat!(ic::X_SQUARE, " Close"); 24 | const L_QUIT: &str = concat!(ic::SIGN_OUT, " Quit"); 25 | 26 | pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, font_size: u16, line_spacing: u16) { 27 | if ui.add(Button::new(L_LOPEN).shortcut_text("Ctrl+O")).clicked() { 28 | gui.fileops.load_file(app.source_file()); 29 | } 30 | if ui.button(L_OPEN_PROCESS).clicked() { 31 | gui.win.open_process.open.toggle(); 32 | } 33 | let mut load = None; 34 | if ui 35 | .add_enabled( 36 | !app.cfg.recent.is_empty(), 37 | Button::new(L_OPEN_PREVIOUS).shortcut_text("Ctrl+P"), 38 | ) 39 | .on_hover_text("Can be used to switch between 2 files quickly for comparison") 40 | .clicked() 41 | { 42 | crate::shell::open_previous(app, &mut load); 43 | } 44 | ui.checkbox(&mut app.preferences.keep_meta, "Keep metadata") 45 | .on_hover_text("Keep metadata when loading a new file"); 46 | ui.menu_button(L_RECENT, |ui| { 47 | app.cfg.recent.retain(|entry| { 48 | let mut retain = true; 49 | let path = entry.file.as_ref().map_or_else( 50 | || String::from("Unnamed file"), 51 | |path| path.display().to_string(), 52 | ); 53 | ui.horizontal(|ui| { 54 | if ui.button(&path).clicked() { 55 | load = Some(entry.clone()); 56 | } 57 | ui.separator(); 58 | if ui.button("📋").clicked() { 59 | set_clipboard_string(&mut app.clipboard, &mut gui.msg_dialog, &path); 60 | } 61 | if ui.button("🗑").clicked() { 62 | retain = false; 63 | } 64 | }); 65 | ui.separator(); 66 | retain 67 | }); 68 | ui.separator(); 69 | ui.horizontal(|ui| { 70 | let mut cap = app.cfg.recent.capacity(); 71 | if ui.add(egui::DragValue::new(&mut cap).prefix("list capacity: ")).changed() { 72 | app.cfg.recent.set_capacity(cap); 73 | } 74 | ui.separator(); 75 | if ui.add_enabled(!app.cfg.recent.is_empty(), Button::new("🗑 Clear all")).clicked() { 76 | app.cfg.recent.clear(); 77 | } 78 | }); 79 | }); 80 | if let Some(args) = load { 81 | app.load_file_args( 82 | args, 83 | None, 84 | &mut gui.msg_dialog, 85 | font_size, 86 | line_spacing, 87 | None, 88 | ); 89 | } 90 | ui.separator(); 91 | if ui 92 | .add_enabled( 93 | matches!(&app.source, Some(src) if src.attr.permissions.write) 94 | && app.data.dirty_region.is_some(), 95 | Button::new(L_SAVE).shortcut_text("Ctrl+S"), 96 | ) 97 | .clicked() 98 | { 99 | msg_if_fail( 100 | app.save(&mut gui.msg_dialog), 101 | "Failed to save", 102 | &mut gui.msg_dialog, 103 | ); 104 | } 105 | if ui.button(L_SAVE_AS).clicked() { 106 | gui.fileops.save_file_as(); 107 | } 108 | if ui.add(Button::new(L_RELOAD).shortcut_text("Ctrl+R")).clicked() { 109 | msg_if_fail(app.reload(), "Failed to reload", &mut gui.msg_dialog); 110 | } 111 | if ui.button(L_AUTO_SAVE_RELOAD).clicked() { 112 | Gui::add_dialog(&mut gui.dialogs, AutoSaveReloadDialog); 113 | } 114 | ui.separator(); 115 | if ui.button(L_CREATE_BACKUP).clicked() { 116 | msg_if_fail( 117 | app.create_backup(), 118 | "Failed to create backup", 119 | &mut gui.msg_dialog, 120 | ); 121 | } 122 | if ui.button(L_RESTORE_BACKUP).clicked() { 123 | msg_if_fail( 124 | app.restore_backup(), 125 | "Failed to restore backup", 126 | &mut gui.msg_dialog, 127 | ); 128 | } 129 | ui.separator(); 130 | if ui.button(L_PREFERENCES).clicked() { 131 | gui.win.preferences.open.toggle(); 132 | } 133 | ui.separator(); 134 | if ui.add(Button::new(L_CLOSE).shortcut_text("Ctrl+W")).clicked() { 135 | app.close_file(); 136 | } 137 | if ui.button(L_QUIT).clicked() { 138 | app.quit_requested = true; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/gui/windows/about.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::{result_ext::AnyhowConv as _, shell::msg_if_fail}, 4 | egui_extras::{Column, TableBuilder}, 5 | std::fmt::Write as _, 6 | sysinfo::System, 7 | }; 8 | 9 | type InfoPair = (&'static str, String); 10 | 11 | pub struct AboutWindow { 12 | pub open: WindowOpen, 13 | sys: System, 14 | info: [InfoPair; 14], 15 | os_name: String, 16 | os_ver: String, 17 | } 18 | 19 | impl Default for AboutWindow { 20 | fn default() -> Self { 21 | Self { 22 | open: Default::default(), 23 | sys: Default::default(), 24 | info: Default::default(), 25 | os_name: System::name().unwrap_or_else(|| "Unknown".into()), 26 | os_ver: System::os_version().unwrap_or_else(|| "Unknown version".into()), 27 | } 28 | } 29 | } 30 | 31 | const MIB: u64 = 1_048_576; 32 | 33 | macro_rules! optenv { 34 | ($name:literal) => { 35 | option_env!($name).unwrap_or("").to_string() 36 | }; 37 | } 38 | 39 | impl super::Window for AboutWindow { 40 | fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { 41 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 42 | if self.open.just_now() { 43 | self.sys.refresh_cpu_all(); 44 | self.sys.refresh_memory(); 45 | self.info = [ 46 | ("Hexerator", String::new()), 47 | ("Version", optenv!("CARGO_PKG_VERSION")), 48 | ("Git SHA", optenv!("VERGEN_GIT_SHA")), 49 | ( 50 | "Commit date", 51 | optenv!("VERGEN_GIT_COMMIT_TIMESTAMP") 52 | .split('T') 53 | .next() 54 | .unwrap_or("error") 55 | .into(), 56 | ), 57 | ( 58 | "Build date", 59 | optenv!("VERGEN_BUILD_TIMESTAMP").split('T').next().unwrap_or("error").into(), 60 | ), 61 | ("Target", optenv!("VERGEN_CARGO_TARGET_TRIPLE")), 62 | ("Debug", optenv!("VERGEN_CARGO_DEBUG")), 63 | ("Opt-level", optenv!("VERGEN_CARGO_OPT_LEVEL")), 64 | ("Built with rustc", optenv!("VERGEN_RUSTC_SEMVER")), 65 | ("System", String::new()), 66 | ("OS", format!("{} {}", self.os_name, self.os_ver)), 67 | ( 68 | "Total memory", 69 | format!("{} MiB", self.sys.total_memory() / MIB), 70 | ), 71 | ( 72 | "Used memory", 73 | format!("{} MiB", self.sys.used_memory() / MIB), 74 | ), 75 | ( 76 | "Available memory", 77 | format!("{} MiB", self.sys.available_memory() / MIB), 78 | ), 79 | ]; 80 | } 81 | info_table(ui, &self.info); 82 | ui.separator(); 83 | ui.vertical_centered_justified(|ui| { 84 | if ui.button("Copy to clipboard").clicked() { 85 | crate::app::set_clipboard_string( 86 | &mut app.clipboard, 87 | &mut gui.msg_dialog, 88 | &clipfmt_info(&self.info), 89 | ); 90 | } 91 | }); 92 | ui.separator(); 93 | ui.heading("Links"); 94 | ui.vertical_centered_justified(|ui| { 95 | let result = try { 96 | if ui.link("📖 Book").clicked() { 97 | open::that(crate::gui::BOOK_URL).how()?; 98 | } 99 | if ui.link(" Git repository").clicked() { 100 | open::that("https://github.com/crumblingstatue/hexerator/").how()?; 101 | } 102 | if ui.link("💬 Discussions forum").clicked() { 103 | open::that("https://github.com/crumblingstatue/hexerator/discussions").how()?; 104 | } 105 | }; 106 | msg_if_fail(result, "Failed to open link", &mut gui.msg_dialog); 107 | ui.separator(); 108 | if ui.button("Close").clicked() { 109 | self.open.set(false); 110 | } 111 | }); 112 | } 113 | 114 | fn title(&self) -> &str { 115 | "About Hexerator" 116 | } 117 | } 118 | 119 | fn info_table(ui: &mut egui::Ui, info: &[InfoPair]) { 120 | ui.push_id(info.as_ptr(), |ui| { 121 | let body_height = ui.text_style_height(&egui::TextStyle::Body); 122 | TableBuilder::new(ui) 123 | .column(Column::auto()) 124 | .column(Column::remainder()) 125 | .resizable(true) 126 | .striped(true) 127 | .body(|mut body| { 128 | for (k, v) in info { 129 | body.row(body_height + 2.0, |mut row| { 130 | row.col(|ui| { 131 | ui.label(*k); 132 | }); 133 | row.col(|ui| { 134 | ui.label(v); 135 | }); 136 | }); 137 | } 138 | }); 139 | }); 140 | } 141 | 142 | fn clipfmt_info(info: &[InfoPair]) -> String { 143 | let mut out = String::new(); 144 | for (k, v) in info { 145 | let _ = writeln!(out, "{k}: {v}"); 146 | } 147 | out 148 | } 149 | -------------------------------------------------------------------------------- /src/gui/windows.rs: -------------------------------------------------------------------------------- 1 | pub use self::{ 2 | file_diff_result::FileDiffResultWindow, 3 | lua_console::{ConMsg, LuaConsoleWindow}, 4 | regions::{RegionsWindow, region_context_menu}, 5 | }; 6 | use { 7 | self::{ 8 | about::AboutWindow, bookmarks::BookmarksWindow, external_command::ExternalCommandWindow, 9 | find_dialog::FindDialog, find_memory_pointers::FindMemoryPointersWindow, 10 | layouts::LayoutsWindow, lua_help::LuaHelpWindow, lua_watch::LuaWatchWindow, 11 | meta_diff::MetaDiffWindow, open_process::OpenProcessWindow, 12 | perspectives::PerspectivesWindow, preferences::PreferencesWindow, 13 | script_manager::ScriptManagerWindow, structs::StructsWindow, vars::VarsWindow, 14 | views::ViewsWindow, zero_partition::ZeroPartition, 15 | }, 16 | super::Gui, 17 | crate::app::App, 18 | egui_sf2g::sf2g::graphics::Font, 19 | lua_editor::LuaEditorWindow, 20 | }; 21 | 22 | mod about; 23 | mod bookmarks; 24 | pub mod debug; 25 | mod external_command; 26 | mod file_diff_result; 27 | mod find_dialog; 28 | mod find_memory_pointers; 29 | mod layouts; 30 | mod lua_console; 31 | mod lua_editor; 32 | mod lua_help; 33 | mod lua_watch; 34 | mod meta_diff; 35 | mod open_process; 36 | mod perspectives; 37 | mod preferences; 38 | mod regions; 39 | mod script_manager; 40 | mod structs; 41 | mod vars; 42 | mod views; 43 | mod zero_partition; 44 | 45 | #[derive(Default)] 46 | pub struct Windows { 47 | pub layouts: LayoutsWindow, 48 | pub views: ViewsWindow, 49 | pub regions: RegionsWindow, 50 | pub bookmarks: BookmarksWindow, 51 | pub find: FindDialog, 52 | pub perspectives: PerspectivesWindow, 53 | pub file_diff_result: FileDiffResultWindow, 54 | pub open_process: OpenProcessWindow, 55 | pub find_memory_pointers: FindMemoryPointersWindow, 56 | pub external_command: ExternalCommandWindow, 57 | pub preferences: PreferencesWindow, 58 | pub about: AboutWindow, 59 | pub vars: VarsWindow, 60 | pub lua_editor: LuaEditorWindow, 61 | pub lua_help: LuaHelpWindow, 62 | pub lua_console: LuaConsoleWindow, 63 | pub lua_watch: Vec, 64 | pub script_manager: ScriptManagerWindow, 65 | pub meta_diff: MetaDiffWindow, 66 | pub zero_partition: ZeroPartition, 67 | pub structs: StructsWindow, 68 | } 69 | 70 | #[derive(Default)] 71 | pub(crate) struct WindowOpen { 72 | open: bool, 73 | just_opened: bool, 74 | } 75 | 76 | impl WindowOpen { 77 | /// Open if closed, close if opened 78 | pub fn toggle(&mut self) { 79 | self.open ^= true; 80 | if self.open { 81 | self.just_opened = true; 82 | } 83 | } 84 | /// Wheter the window is open 85 | fn is(&self) -> bool { 86 | self.open 87 | } 88 | /// Set whether the window is open 89 | pub fn set(&mut self, open: bool) { 90 | if !self.open && open { 91 | self.just_opened = true; 92 | } 93 | self.open = open; 94 | } 95 | /// Whether the window was opened just now (this frame) 96 | fn just_now(&self) -> bool { 97 | self.just_opened 98 | } 99 | } 100 | 101 | struct WinCtx<'a> { 102 | ui: &'a mut egui::Ui, 103 | gui: &'a mut Gui, 104 | app: &'a mut App, 105 | lua: &'a mlua::Lua, 106 | font_size: u16, 107 | line_spacing: u16, 108 | font: &'a Font, 109 | } 110 | 111 | trait Window { 112 | fn ui(&mut self, ctx: WinCtx); 113 | fn title(&self) -> &str; 114 | } 115 | 116 | impl Windows { 117 | pub(crate) fn update( 118 | ctx: &egui::Context, 119 | gui: &mut Gui, 120 | app: &mut App, 121 | lua: &mlua::Lua, 122 | font_size: u16, 123 | line_spacing: u16, 124 | font: &Font, 125 | ) { 126 | let mut open; 127 | macro_rules! windows { 128 | ($($field:ident,)*) => { 129 | $( 130 | let mut win = std::mem::take(&mut gui.win.$field); 131 | open = win.open.is(); 132 | egui::Window::new(win.title()).open(&mut open).show(ctx, |ui| win.ui(WinCtx{ ui, gui, app, lua, font_size, line_spacing, font })); 133 | win.open.just_opened = false; 134 | if !open { 135 | win.open.set(false); 136 | } 137 | std::mem::swap(&mut gui.win.$field, &mut win); 138 | )* 139 | }; 140 | } 141 | windows!( 142 | find, 143 | regions, 144 | bookmarks, 145 | layouts, 146 | views, 147 | vars, 148 | perspectives, 149 | file_diff_result, 150 | meta_diff, 151 | open_process, 152 | find_memory_pointers, 153 | external_command, 154 | preferences, 155 | lua_editor, 156 | lua_help, 157 | lua_console, 158 | script_manager, 159 | about, 160 | zero_partition, 161 | structs, 162 | ); 163 | 164 | let mut watch_windows = std::mem::take(&mut gui.win.lua_watch); 165 | let mut i = 0; 166 | watch_windows.retain_mut(|win| { 167 | let mut retain = true; 168 | egui::Window::new(&win.name) 169 | .id(egui::Id::new("watch_w").with(i)) 170 | .open(&mut retain) 171 | .show(ctx, |ui| { 172 | win.ui(WinCtx { 173 | ui, 174 | gui, 175 | app, 176 | lua, 177 | font_size, 178 | line_spacing, 179 | font, 180 | }); 181 | }); 182 | i += 1; 183 | retain 184 | }); 185 | std::mem::swap(&mut gui.win.lua_watch, &mut watch_windows); 186 | } 187 | pub fn add_lua_watch_window(&mut self) { 188 | self.lua_watch.push(LuaWatchWindow::default()); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/gui/root_ctx_menu.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::Gui, 3 | crate::{app::App, meta::ViewKey, view::ViewportScalar}, 4 | constcat::concat, 5 | egui_phosphor::regular as ic, 6 | }; 7 | 8 | const L_SELECTION: &str = concat!(ic::SELECTION, " Selection"); 9 | const L_REGION_PROPS: &str = concat!(ic::RULER, " Region properties..."); 10 | const L_VIEW_PROPS: &str = concat!(ic::EYE, " View properties..."); 11 | const L_CHANGE_THIS_VIEW: &str = concat!(ic::SWAP, " Change this view to"); 12 | const L_REMOVE_FROM_LAYOUT: &str = concat!(ic::TRASH, " Remove from layout"); 13 | const L_OPEN_BOOKMARK: &str = concat!(ic::BOOKMARK, " Open bookmark"); 14 | const L_ADD_BOOKMARK: &str = concat!(ic::BOOKMARK, " Add bookmark"); 15 | const L_LAYOUT_PROPS: &str = concat!(ic::LAYOUT, " Layout properties..."); 16 | const L_LAYOUTS: &str = concat!(ic::LAYOUT, " Layouts"); 17 | 18 | pub struct ContextMenu { 19 | pos: egui::Pos2, 20 | data: ContextMenuData, 21 | } 22 | 23 | impl ContextMenu { 24 | pub fn new(mx: ViewportScalar, my: ViewportScalar, data: ContextMenuData) -> Self { 25 | Self { 26 | pos: egui::pos2(f32::from(mx), f32::from(my)), 27 | data, 28 | } 29 | } 30 | } 31 | 32 | pub struct ContextMenuData { 33 | pub view: Option, 34 | pub byte_off: Option, 35 | } 36 | 37 | /// Yoinked from egui source code 38 | fn set_menu_style(style: &mut egui::Style) { 39 | style.spacing.button_padding = egui::vec2(2.0, 0.0); 40 | style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE; 41 | style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; 42 | style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT; 43 | style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE; 44 | style.wrap_mode = Some(egui::TextWrapMode::Extend); 45 | } 46 | 47 | /// Returns whether to keep root context menu open 48 | #[must_use] 49 | pub(super) fn show(menu: &ContextMenu, ctx: &egui::Context, app: &mut App, gui: &mut Gui) -> bool { 50 | let mut close = false; 51 | egui::Area::new("root_ctx_menu".into()) 52 | .kind(egui::UiKind::Menu) 53 | .order(egui::Order::Foreground) 54 | .fixed_pos(menu.pos) 55 | .default_width(ctx.style().spacing.menu_width) 56 | .sense(egui::Sense::hover()) 57 | .show(ctx, |ui| { 58 | set_menu_style(ui.style_mut()); 59 | egui::Frame::menu(ui.style()).show(ui, |ui| { 60 | ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { 61 | menu_inner_ui(app, ui, gui, &mut close, menu); 62 | }); 63 | }); 64 | }); 65 | !close 66 | } 67 | 68 | fn menu_inner_ui( 69 | app: &mut App, 70 | ui: &mut egui::Ui, 71 | gui: &mut Gui, 72 | close: &mut bool, 73 | menu: &ContextMenu, 74 | ) { 75 | if let Some(sel) = app.hex_ui.selection() { 76 | ui.separator(); 77 | if crate::gui::selection_menu::selection_menu( 78 | L_SELECTION, 79 | ui, 80 | app, 81 | &mut gui.dialogs, 82 | &mut gui.msg_dialog, 83 | &mut gui.win.regions, 84 | sel, 85 | &mut gui.fileops, 86 | ) { 87 | *close = true; 88 | } 89 | } 90 | if let Some(view) = menu.data.view { 91 | ui.separator(); 92 | if ui.button(L_REGION_PROPS).clicked() { 93 | gui.win.regions.selected_key = Some(app.region_key_for_view(view)); 94 | gui.win.regions.open.set(true); 95 | *close = true; 96 | } 97 | if ui.button(L_VIEW_PROPS).clicked() { 98 | gui.win.views.selected = view; 99 | gui.win.views.open.set(true); 100 | *close = true; 101 | } 102 | ui.menu_button(L_CHANGE_THIS_VIEW, |ui| { 103 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 104 | let Some(layout) = app.meta_state.meta.layouts.get_mut(app.hex_ui.current_layout) 105 | else { 106 | return; 107 | }; 108 | for (k, v) in 109 | app.meta_state.meta.views.iter().filter(|(k, _)| !layout.contains_view(*k)) 110 | { 111 | if ui.button(&v.name).clicked() { 112 | layout.change_view_type(view, k); 113 | 114 | *close = true; 115 | return; 116 | } 117 | } 118 | }); 119 | if ui.button(L_REMOVE_FROM_LAYOUT).clicked() 120 | && let Some(layout) = app.meta_state.meta.layouts.get_mut(app.hex_ui.current_layout) 121 | { 122 | layout.remove_view(view); 123 | if app.hex_ui.focused_view == Some(view) { 124 | let first_view = layout.view_grid.first().and_then(|row| row.first()); 125 | app.hex_ui.focused_view = first_view.cloned(); 126 | } 127 | *close = true; 128 | } 129 | } 130 | if let Some(byte_off) = menu.data.byte_off { 131 | ui.separator(); 132 | match app.meta_state.meta.bookmarks.iter().position(|bm| bm.offset == byte_off) { 133 | Some(pos) => { 134 | if ui.button(L_OPEN_BOOKMARK).clicked() { 135 | gui.win.bookmarks.open.set(true); 136 | gui.win.bookmarks.selected = Some(pos); 137 | *close = true; 138 | } 139 | } 140 | None => { 141 | if ui.button(L_ADD_BOOKMARK).clicked() { 142 | crate::gui::add_new_bookmark(app, gui, byte_off); 143 | *close = true; 144 | } 145 | } 146 | } 147 | } 148 | ui.separator(); 149 | if ui.button(L_LAYOUT_PROPS).clicked() { 150 | gui.win.layouts.open.toggle(); 151 | *close = true; 152 | } 153 | ui.menu_button(L_LAYOUTS, |ui| { 154 | for (key, layout) in app.meta_state.meta.layouts.iter() { 155 | if ui.button(&layout.name).clicked() { 156 | App::switch_layout(&mut app.hex_ui, &app.meta_state.meta, key); 157 | 158 | *close = true; 159 | } 160 | } 161 | }); 162 | } 163 | -------------------------------------------------------------------------------- /src/data.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{damage_region::DamageRegion, meta::region::Region}, 3 | std::ops::{Deref, DerefMut}, 4 | }; 5 | 6 | /// The data we are viewing/editing 7 | #[derive(Default, Debug)] 8 | pub struct Data { 9 | data: Option, 10 | /// The region that was changed compared to the source 11 | pub dirty_region: Option, 12 | /// Original data length. Compared with current data length to detect truncation. 13 | pub orig_data_len: usize, 14 | } 15 | 16 | enum DataProvider { 17 | Vec(Vec), 18 | MmapMut(memmap2::MmapMut), 19 | MmapImmut(memmap2::Mmap), 20 | } 21 | 22 | impl std::fmt::Debug for DataProvider { 23 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 | match self { 25 | Self::Vec(..) => f.write_str("Vec"), 26 | Self::MmapMut(..) => f.write_str("MmapMut"), 27 | Self::MmapImmut(..) => f.write_str("MmapImmut"), 28 | } 29 | } 30 | } 31 | 32 | impl Data { 33 | pub(crate) fn clean_from_buf(buf: Vec) -> Self { 34 | Self { 35 | orig_data_len: buf.len(), 36 | data: Some(DataProvider::Vec(buf)), 37 | dirty_region: None, 38 | } 39 | } 40 | pub(crate) fn new_mmap_mut(mmap: memmap2::MmapMut) -> Self { 41 | Self { 42 | orig_data_len: mmap.len(), 43 | data: Some(DataProvider::MmapMut(mmap)), 44 | dirty_region: None, 45 | } 46 | } 47 | pub(crate) fn new_mmap_immut(mmap: memmap2::Mmap) -> Self { 48 | Self { 49 | orig_data_len: mmap.len(), 50 | data: Some(DataProvider::MmapImmut(mmap)), 51 | dirty_region: None, 52 | } 53 | } 54 | /// Drop any expensive allocations and reset to "empty" state 55 | pub(crate) fn close(&mut self) { 56 | self.data = None; 57 | self.dirty_region = None; 58 | } 59 | pub(crate) fn widen_dirty_region(&mut self, damage: DamageRegion) { 60 | match &mut self.dirty_region { 61 | Some(dirty_region) => { 62 | if damage.begin() < dirty_region.begin { 63 | dirty_region.begin = damage.begin(); 64 | } 65 | if damage.begin() > dirty_region.end { 66 | dirty_region.end = damage.begin(); 67 | } 68 | let end = damage.end(); 69 | { 70 | if end < dirty_region.begin { 71 | gamedebug_core::per!("TODO: logic error in widen_dirty_region"); 72 | return; 73 | } 74 | if end > dirty_region.end { 75 | dirty_region.end = end; 76 | } 77 | } 78 | } 79 | None => { 80 | self.dirty_region = Some(Region { 81 | begin: damage.begin(), 82 | end: damage.end(), 83 | }); 84 | } 85 | } 86 | } 87 | /// Clears the dirty region (asserts data is same as source), and sets length same as source 88 | pub(crate) fn undirty(&mut self) { 89 | self.dirty_region = None; 90 | self.orig_data_len = self.len(); 91 | } 92 | 93 | pub(crate) fn resize(&mut self, new_len: usize, value: u8) { 94 | match &mut self.data { 95 | Some(DataProvider::Vec(v)) => v.resize(new_len, value), 96 | etc => { 97 | eprintln!("Data::resize: Unimplemented for {etc:?}"); 98 | } 99 | } 100 | } 101 | 102 | pub(crate) fn extend_from_slice(&mut self, slice: &[u8]) { 103 | match &mut self.data { 104 | Some(DataProvider::Vec(v)) => v.extend_from_slice(slice), 105 | etc => { 106 | eprintln!("Data::extend_from_slice: Unimplemented for {etc:?}"); 107 | } 108 | } 109 | } 110 | 111 | pub(crate) fn drain(&mut self, range: std::ops::Range) { 112 | match &mut self.data { 113 | Some(DataProvider::Vec(v)) => { 114 | v.drain(range); 115 | } 116 | etc => { 117 | eprintln!("Data::drain: Unimplemented for {etc:?}"); 118 | } 119 | } 120 | } 121 | 122 | pub(crate) fn zero_fill_region(&mut self, region: Region) { 123 | let range = region.begin..=region.end; 124 | if let Some(data) = self.get_mut(range.clone()) { 125 | data.fill(0); 126 | self.widen_dirty_region(DamageRegion::RangeInclusive(range)); 127 | } 128 | } 129 | 130 | pub(crate) fn reload_from_file( 131 | &mut self, 132 | src_args: &crate::args::SourceArgs, 133 | file: &mut std::fs::File, 134 | ) -> anyhow::Result<()> { 135 | match &mut self.data { 136 | Some(DataProvider::Vec(buf)) => { 137 | *buf = crate::app::read_contents(src_args, file)?; 138 | } 139 | etc => anyhow::bail!("Reload not supported for {etc:?}"), 140 | } 141 | self.dirty_region = None; 142 | Ok(()) 143 | } 144 | 145 | pub(crate) fn mod_range( 146 | &mut self, 147 | range: std::ops::RangeInclusive, 148 | mut f: impl FnMut(&mut u8), 149 | ) { 150 | for byte in self.get_mut(range.clone()).into_iter().flatten() { 151 | f(byte); 152 | } 153 | self.widen_dirty_region(range.into()); 154 | } 155 | } 156 | 157 | impl Deref for Data { 158 | type Target = [u8]; 159 | 160 | fn deref(&self) -> &Self::Target { 161 | match &self.data { 162 | Some(DataProvider::Vec(v)) => v, 163 | Some(DataProvider::MmapMut(map)) => map, 164 | Some(DataProvider::MmapImmut(map)) => map, 165 | None => &[], 166 | } 167 | } 168 | } 169 | 170 | impl DerefMut for Data { 171 | fn deref_mut(&mut self) -> &mut Self::Target { 172 | match &mut self.data { 173 | Some(DataProvider::Vec(v)) => v, 174 | Some(DataProvider::MmapMut(map)) => map, 175 | Some(DataProvider::MmapImmut(_)) => &mut [], 176 | None => &mut [], 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/layout.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | meta::{PerspectiveMap, RegionMap, ViewKey, ViewMap, region::Region}, 4 | view::{ViewportRect, ViewportVec}, 5 | }, 6 | serde::{Deserialize, Serialize}, 7 | std::cmp::{max, min}, 8 | }; 9 | 10 | /// A view layout grid for laying out views. 11 | #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] 12 | pub struct Layout { 13 | pub name: String, 14 | pub view_grid: Vec>, 15 | /// Margin around views 16 | #[serde(default = "default_margin")] 17 | pub margin: ViewportVec, 18 | } 19 | 20 | pub const fn default_margin() -> ViewportVec { 21 | ViewportVec { x: 6, y: 6 } 22 | } 23 | 24 | impl Layout { 25 | /// Iterate through all view keys 26 | pub fn iter(&self) -> impl Iterator + '_ { 27 | self.view_grid.iter().flatten().cloned() 28 | } 29 | 30 | pub(crate) fn idx_of_key(&self, key: ViewKey) -> Option<[usize; 2]> { 31 | self.view_grid.iter().enumerate().find_map(|(row_idx, row)| { 32 | let col_pos = row.iter().position(|k| *k == key)?; 33 | Some([row_idx, col_pos]) 34 | }) 35 | } 36 | 37 | pub(crate) fn view_containing_region( 38 | &self, 39 | reg: &Region, 40 | meta: &crate::meta::Meta, 41 | ) -> Option { 42 | self.iter() 43 | .find(|view_key| meta.views[*view_key].view.contains_region(reg, meta)) 44 | } 45 | 46 | pub(crate) fn contains_view(&self, key: ViewKey) -> bool { 47 | self.iter().any(|k| k == key) 48 | } 49 | 50 | pub(crate) fn remove_view(&mut self, rem_key: ViewKey) { 51 | self.view_grid.retain_mut(|row| { 52 | row.retain(|view_key| *view_key != rem_key); 53 | !row.is_empty() 54 | }); 55 | } 56 | 57 | pub(crate) fn remove_dangling(&mut self, map: &ViewMap) { 58 | self.view_grid.retain_mut(|row| { 59 | row.retain(|view_key| { 60 | let mut retain = true; 61 | if !map.contains_key(*view_key) { 62 | eprintln!( 63 | "Removed dangling view {:?} from layout {}", 64 | view_key, self.name 65 | ); 66 | retain = false; 67 | } 68 | retain 69 | }); 70 | !row.is_empty() 71 | }); 72 | } 73 | 74 | pub(crate) fn change_view_type(&mut self, current: ViewKey, new: ViewKey) { 75 | if let Some(current_key) = self.view_grid.iter_mut().flatten().find(|k| **k == current) { 76 | *current_key = new; 77 | } 78 | } 79 | } 80 | 81 | pub fn do_auto_layout( 82 | layout: &Layout, 83 | view_map: &mut ViewMap, 84 | hex_iface_rect: &ViewportRect, 85 | perspectives: &PerspectiveMap, 86 | regions: &RegionMap, 87 | ) { 88 | let layout_n_rows = i16::try_from(layout.view_grid.len()).expect("Too many rows in layout"); 89 | let mut total_h = 0; 90 | // Determine sizes 91 | for row in &layout.view_grid { 92 | let max_allowed_h = 93 | (hex_iface_rect.h - (layout.margin.y * (layout_n_rows + 1))) / layout_n_rows; 94 | let row_n_cols = i16::try_from(row.len()).expect("Too many columns in layout"); 95 | let mut total_row_w = 0; 96 | let mut max_h = 0; 97 | for &view_key in row { 98 | let max_allowed_w = 99 | (hex_iface_rect.w - (layout.margin.x * (row_n_cols + 1))) / row_n_cols; 100 | let view = &mut view_map[view_key].view; 101 | let max_needed_size = view.max_needed_size(perspectives, regions); 102 | let w = min(max_needed_size.x, max_allowed_w); 103 | let h = min(max_needed_size.y, max_allowed_h); 104 | view.viewport_rect.w = w; 105 | total_row_w += w; 106 | view.viewport_rect.h = h; 107 | max_h = max(max_h, view.viewport_rect.h); 108 | } 109 | total_h += max_h; 110 | // Distribute remaining width to views in order 111 | let w_to_fill_viewport = hex_iface_rect.w - (layout.margin.x * (row_n_cols + 1)); 112 | let mut w_remaining = w_to_fill_viewport - total_row_w; 113 | for &view_key in row { 114 | if w_remaining <= 0 { 115 | break; 116 | } 117 | let view = &mut view_map[view_key].view; 118 | let max_needed_w = view.max_needed_size(perspectives, regions).x; 119 | let missing_for_max_needed = max_needed_w - view.viewport_rect.w; 120 | let can_add = min(missing_for_max_needed, w_remaining); 121 | view.viewport_rect.w += can_add; 122 | w_remaining -= can_add; 123 | } 124 | } 125 | // Distribute remaining height to rows in order 126 | let h_to_fill_viewport = hex_iface_rect.h - (layout.margin.y * (layout_n_rows + 1)); 127 | let mut h_remaining = h_to_fill_viewport - total_h; 128 | for row in &layout.view_grid { 129 | if h_remaining <= 0 { 130 | break; 131 | } 132 | let mut max_can_add = 0; 133 | for &view_key in row { 134 | let view = &mut view_map[view_key].view; 135 | let max_needed_h = view.max_needed_size(perspectives, regions).y; 136 | let missing_for_max_needed = max_needed_h - view.viewport_rect.h; 137 | let can_add = min(missing_for_max_needed, h_remaining); 138 | max_can_add = max(max_can_add, can_add); 139 | view.viewport_rect.h += can_add; 140 | } 141 | h_remaining -= max_can_add; 142 | } 143 | // Lay out 144 | let mut x_cursor = hex_iface_rect.x + layout.margin.x; 145 | let mut y_cursor = hex_iface_rect.y + layout.margin.y; 146 | for row in &layout.view_grid { 147 | let mut max_h = 0; 148 | for &view_key in row { 149 | let view = &mut view_map[view_key].view; 150 | view.viewport_rect.x = x_cursor; 151 | view.viewport_rect.y = y_cursor; 152 | x_cursor += view.viewport_rect.w + layout.margin.x; 153 | max_h = max(max_h, view.viewport_rect.h); 154 | } 155 | x_cursor = hex_iface_rect.x + layout.margin.x; 156 | y_cursor += max_h + layout.margin.y; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/gui/windows/find_memory_pointers.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::shell::msg_fail, 4 | egui_extras::{Column, TableBuilder}, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct FindMemoryPointersWindow { 9 | pub open: WindowOpen, 10 | pointers: Vec, 11 | filter_write: bool, 12 | filter_exec: bool, 13 | } 14 | 15 | #[derive(Clone, Copy)] 16 | struct PtrEntry { 17 | src_idx: usize, 18 | ptr: usize, 19 | range_idx: usize, 20 | write: bool, 21 | execute: bool, 22 | } 23 | 24 | impl super::Window for FindMemoryPointersWindow { 25 | fn ui( 26 | &mut self, 27 | WinCtx { 28 | ui, 29 | gui, 30 | app, 31 | font_size, 32 | line_spacing, 33 | .. 34 | }: WinCtx, 35 | ) { 36 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 37 | let Some(pid) = gui.win.open_process.selected_pid else { 38 | ui.label("No selected pid."); 39 | return; 40 | }; 41 | if self.open.just_now() { 42 | for (i, wnd) in app.data.array_windows::<{ (usize::BITS / 8) as usize }>().enumerate() { 43 | let ptr = usize::from_le_bytes(*wnd); 44 | if let Some(pos) = gui.win.open_process.map_ranges.iter().position(|range| { 45 | range.is_read() && range.start() <= ptr && range.start() + range.size() >= ptr 46 | }) { 47 | let range = &gui.win.open_process.map_ranges[pos]; 48 | self.pointers.push(PtrEntry { 49 | src_idx: i, 50 | ptr, 51 | range_idx: pos, 52 | write: range.is_write(), 53 | execute: range.is_exec(), 54 | }); 55 | } 56 | } 57 | } 58 | let mut action = Action::None; 59 | TableBuilder::new(ui) 60 | .column(Column::auto()) 61 | .column(Column::auto()) 62 | .column(Column::auto()) 63 | .column(Column::remainder()) 64 | .striped(true) 65 | .resizable(true) 66 | .header(20.0, |mut row| { 67 | row.col(|ui| { 68 | ui.label("Location"); 69 | }); 70 | row.col(|ui| { 71 | if ui.button("Region").clicked() { 72 | self.pointers.sort_by_key(|p| { 73 | gui.win.open_process.map_ranges[p.range_idx].filename() 74 | }); 75 | } 76 | }); 77 | row.col(|ui| { 78 | ui.menu_button("w/x", |ui| { 79 | ui.checkbox(&mut self.filter_write, "Write"); 80 | ui.checkbox(&mut self.filter_exec, "Execute"); 81 | }); 82 | }); 83 | row.col(|ui| { 84 | if ui.button("Pointer").clicked() { 85 | self.pointers.sort_by_key(|p| p.ptr); 86 | } 87 | }); 88 | }) 89 | .body(|body| { 90 | let mut filtered = self.pointers.clone(); 91 | filtered.retain(|ptr| { 92 | if self.filter_exec && !ptr.execute { 93 | return false; 94 | } 95 | if self.filter_write && !ptr.write { 96 | return false; 97 | } 98 | true 99 | }); 100 | body.rows(20.0, filtered.len(), |mut row| { 101 | let en = &filtered[row.index()]; 102 | row.col(|ui| { 103 | if ui.link(format!("{:X}", en.src_idx)).clicked() { 104 | action = Action::Goto(en.src_idx); 105 | } 106 | }); 107 | row.col(|ui| { 108 | let range = &gui.win.open_process.map_ranges[en.range_idx]; 109 | ui.label(range.filename().map_or_else( 110 | || format!(" @ {:X} (size: {})", range.start(), range.size()), 111 | |p| p.display().to_string(), 112 | )); 113 | }); 114 | row.col(|ui| { 115 | let range = &gui.win.open_process.map_ranges[en.range_idx]; 116 | ui.label(format!( 117 | "{}{}", 118 | if range.is_write() { "w" } else { "" }, 119 | if range.is_exec() { "x" } else { "" } 120 | )); 121 | }); 122 | row.col(|ui| { 123 | let range = &gui.win.open_process.map_ranges[en.range_idx]; 124 | if ui.link(format!("{:X}", en.ptr)).clicked() { 125 | match app.load_proc_memory( 126 | pid, 127 | range.start(), 128 | range.size(), 129 | range.is_write(), 130 | &mut gui.msg_dialog, 131 | font_size, 132 | line_spacing, 133 | ) { 134 | Ok(()) => action = Action::Goto(en.ptr - range.start()), 135 | Err(e) => { 136 | msg_fail(&e, "failed to load proc memory", &mut gui.msg_dialog); 137 | } 138 | } 139 | } 140 | }); 141 | }); 142 | }); 143 | match action { 144 | Action::Goto(off) => { 145 | app.center_view_on_offset(off); 146 | app.edit_state.set_cursor(off); 147 | app.hex_ui.flash_cursor(); 148 | } 149 | Action::None => {} 150 | } 151 | } 152 | 153 | fn title(&self) -> &str { 154 | "Find memory pointers" 155 | } 156 | } 157 | 158 | enum Action { 159 | Goto(usize), 160 | None, 161 | } 162 | -------------------------------------------------------------------------------- /src/gui/top_menu/edit.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | app::{ 4 | App, 5 | command::{Cmd, perform_command}, 6 | }, 7 | gui::{Gui, dialogs::TruncateDialog, message_dialog::Icon}, 8 | result_ext::AnyhowConv as _, 9 | shell::msg_if_fail, 10 | }, 11 | constcat::concat, 12 | egui::Button, 13 | egui_phosphor::regular as ic, 14 | mlua::Lua, 15 | }; 16 | 17 | const L_FIND: &str = concat!(ic::MAGNIFYING_GLASS, " Find..."); 18 | const L_SELECTION: &str = concat!(ic::SELECTION, " Selection"); 19 | const L_SELECT_A: &str = "🅰 Set select a"; 20 | const L_SELECT_B: &str = "🅱 Set select b"; 21 | const L_SELECT_ALL: &str = concat!(ic::SELECTION_ALL, " Select all in region"); 22 | const L_SELECT_ROW: &str = concat!(ic::ARROWS_HORIZONTAL, " Select row"); 23 | const L_SELECT_COL: &str = concat!(ic::ARROWS_VERTICAL, " Select column"); 24 | const L_EXTERNAL_COMMAND: &str = concat!(ic::TERMINAL_WINDOW, " External command..."); 25 | const L_INC_BYTE: &str = concat!(ic::PLUS, " Inc byte(s)"); 26 | const L_DEC_BYTE: &str = concat!(ic::MINUS, " Dec byte(s)"); 27 | const L_PASTE_AT_CURSOR: &str = concat!(ic::CLIPBOARD_TEXT, " Paste at cursor"); 28 | const L_TRUNCATE_EXTEND: &str = concat!(ic::SCISSORS, " Truncate/Extend..."); 29 | 30 | pub fn ui( 31 | ui: &mut egui::Ui, 32 | gui: &mut Gui, 33 | app: &mut App, 34 | lua: &Lua, 35 | font_size: u16, 36 | line_spacing: u16, 37 | ) { 38 | if ui.add(Button::new(L_FIND).shortcut_text("Ctrl+F")).clicked() { 39 | gui.win.find.open.toggle(); 40 | } 41 | ui.separator(); 42 | match app.hex_ui.selection() { 43 | Some(sel) => { 44 | if crate::gui::selection_menu::selection_menu( 45 | L_SELECTION, 46 | ui, 47 | app, 48 | &mut gui.dialogs, 49 | &mut gui.msg_dialog, 50 | &mut gui.win.regions, 51 | sel, 52 | &mut gui.fileops, 53 | ) {} 54 | } 55 | None => { 56 | ui.label(""); 57 | } 58 | } 59 | if ui.add(Button::new(L_SELECT_A).shortcut_text("shift+1")).clicked() { 60 | app.hex_ui.select_a = Some(app.edit_state.cursor); 61 | } 62 | if ui.add(Button::new(L_SELECT_B).shortcut_text("shift+2")).clicked() { 63 | app.hex_ui.select_b = Some(app.edit_state.cursor); 64 | } 65 | if ui.add(Button::new(L_SELECT_ALL).shortcut_text("Ctrl+A")).clicked() { 66 | app.focused_view_select_all(); 67 | } 68 | if ui.add(Button::new(L_SELECT_ROW)).clicked() { 69 | app.focused_view_select_row(); 70 | } 71 | if ui.add(Button::new(L_SELECT_COL)).clicked() { 72 | app.focused_view_select_col(); 73 | } 74 | ui.separator(); 75 | if ui.add(Button::new(L_EXTERNAL_COMMAND).shortcut_text("Ctrl+E")).clicked() { 76 | gui.win.external_command.open.toggle(); 77 | } 78 | ui.separator(); 79 | if ui 80 | .add(Button::new(L_INC_BYTE).shortcut_text("Ctrl+=")) 81 | .on_hover_text("Increase byte(s) of selection or at cursor") 82 | .clicked() 83 | { 84 | app.inc_byte_or_bytes(); 85 | } 86 | if ui 87 | .add(Button::new(L_DEC_BYTE).shortcut_text("Ctrl+-")) 88 | .on_hover_text("Decrease byte(s) of selection or at cursor") 89 | .clicked() 90 | { 91 | app.dec_byte_or_bytes(); 92 | } 93 | ui.menu_button(L_PASTE_AT_CURSOR, |ui| { 94 | if ui.button("Hex text from clipboard").clicked() { 95 | let s = crate::app::get_clipboard_string(&mut app.clipboard, &mut gui.msg_dialog); 96 | let cursor = app.edit_state.cursor; 97 | let result = try { 98 | let bytes = s 99 | .split_ascii_whitespace() 100 | .map(|s| u8::from_str_radix(s, 16)) 101 | .collect::, _>>() 102 | .how()?; 103 | if cursor + bytes.len() < app.data.len() { 104 | perform_command( 105 | app, 106 | Cmd::PasteBytes { at: cursor, bytes }, 107 | gui, 108 | lua, 109 | font_size, 110 | line_spacing, 111 | ); 112 | } else { 113 | gui.msg_dialog.open( 114 | Icon::Warn, 115 | "Prompt", 116 | "Paste overflows the document. What do do?", 117 | ); 118 | gui.msg_dialog.custom_button_row_ui(Box::new(move |ui, payload, cmd| { 119 | if ui.button("Cancel paste").clicked() { 120 | payload.close = true; 121 | } else if ui.button("Extend document").clicked() { 122 | cmd.push(Cmd::ExtendDocument { 123 | new_len: cursor + bytes.len(), 124 | }); 125 | cmd.push(Cmd::PasteBytes { 126 | at: cursor, 127 | bytes: bytes.clone(), 128 | }); 129 | payload.close = true; 130 | } else if ui.button("Shorten paste").clicked() { 131 | } 132 | })); 133 | } 134 | }; 135 | msg_if_fail(result, "Hex text paste error", &mut gui.msg_dialog); 136 | } 137 | }); 138 | ui.separator(); 139 | ui.checkbox(&mut app.preferences.move_edit_cursor, "Move edit cursor") 140 | .on_hover_text( 141 | "With the cursor keys in edit mode, move edit cursor by default.\n\ 142 | Otherwise, block cursor is moved. Can use ctrl+cursor keys for 143 | the other behavior.", 144 | ); 145 | ui.checkbox(&mut app.preferences.quick_edit, "Quick edit").on_hover_text( 146 | "Immediately apply editing results, instead of having to type a \ 147 | value to completion or press enter", 148 | ); 149 | ui.checkbox(&mut app.preferences.sticky_edit, "Sticky edit") 150 | .on_hover_text("Don't automatically move cursor after editing is finished"); 151 | ui.separator(); 152 | if ui.button(L_TRUNCATE_EXTEND).clicked() { 153 | Gui::add_dialog( 154 | &mut gui.dialogs, 155 | TruncateDialog::new(app.data.len(), app.hex_ui.selection()), 156 | ); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/gui/message_dialog.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::app::command::CommandQueue, 3 | core::f32, 4 | egui::Color32, 5 | std::{backtrace::Backtrace, collections::VecDeque}, 6 | }; 7 | 8 | #[derive(Default)] 9 | pub struct MessageDialog { 10 | payloads: VecDeque, 11 | } 12 | 13 | pub struct Payload { 14 | pub title: String, 15 | pub desc: String, 16 | pub icon: Icon, 17 | pub buttons_ui_fn: Option>, 18 | pub backtrace: Option, 19 | pub show_backtrace: bool, 20 | pub close: bool, 21 | } 22 | 23 | #[derive(Default)] 24 | pub enum Icon { 25 | #[default] 26 | None, 27 | Info, 28 | Warn, 29 | Error, 30 | } 31 | 32 | pub(crate) type UiFn = dyn FnMut(&mut egui::Ui, &mut Payload, &mut CommandQueue); 33 | 34 | // Colors and icon text are copied from egui-toast, for visual consistency 35 | // https://github.com/urholaukkarinen/egui-toast 36 | impl Icon { 37 | fn color(&self) -> Color32 { 38 | match self { 39 | Self::None => Color32::default(), 40 | Self::Info => Color32::from_rgb(0, 155, 255), 41 | Self::Warn => Color32::from_rgb(255, 212, 0), 42 | Self::Error => Color32::from_rgb(255, 32, 0), 43 | } 44 | } 45 | fn utf8(&self) -> &'static str { 46 | match self { 47 | Self::None => "", 48 | Self::Info => "ℹ", 49 | Self::Warn => "⚠", 50 | Self::Error => "❗", 51 | } 52 | } 53 | fn hover_text(&self) -> String { 54 | let label = match self { 55 | Self::None => "", 56 | Self::Info => "Info", 57 | Self::Warn => "Warning", 58 | Self::Error => "Error", 59 | }; 60 | format!("{label}\n\nClick to copy message to clipboard") 61 | } 62 | fn is_set(&self) -> bool { 63 | !matches!(self, Self::None) 64 | } 65 | } 66 | 67 | impl MessageDialog { 68 | pub(crate) fn open(&mut self, icon: Icon, title: impl Into, desc: impl Into) { 69 | self.payloads.push_back(Payload { 70 | title: title.into(), 71 | desc: desc.into(), 72 | icon, 73 | buttons_ui_fn: None, 74 | backtrace: None, 75 | show_backtrace: false, 76 | close: false, 77 | }); 78 | } 79 | pub(crate) fn custom_button_row_ui(&mut self, f: Box) { 80 | if let Some(front) = self.payloads.front_mut() { 81 | front.buttons_ui_fn = Some(f); 82 | } 83 | } 84 | pub(crate) fn show( 85 | &mut self, 86 | ctx: &egui::Context, 87 | cb: &mut arboard::Clipboard, 88 | cmd: &mut CommandQueue, 89 | ) { 90 | let payloads_len = self.payloads.len(); 91 | let Some(payload) = self.payloads.front_mut() else { 92 | return; 93 | }; 94 | let mut close = false; 95 | egui::Modal::new("msg_dialog_popup".into()).show(ctx, |ui| { 96 | ui.horizontal(|ui| { 97 | ui.heading(&payload.title); 98 | if payloads_len > 1 { 99 | ui.label(format!("({} more)", payloads_len - 1)); 100 | } 101 | }); 102 | ui.vertical_centered_justified(|ui| { 103 | ui.horizontal(|ui| { 104 | if payload.icon.is_set() 105 | && ui 106 | .add( 107 | egui::Label::new( 108 | egui::RichText::new(payload.icon.utf8()) 109 | .color(payload.icon.color()) 110 | .size(32.0), 111 | ) 112 | .sense(egui::Sense::click()), 113 | ) 114 | .on_hover_text(payload.icon.hover_text()) 115 | .clicked() 116 | && let Err(e) = cb.set_text(payload.desc.clone()) 117 | { 118 | gamedebug_core::per!("Clipboard set error: {e:?}"); 119 | } 120 | ui.label(&payload.desc); 121 | }); 122 | if let Some(bt) = &payload.backtrace { 123 | ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { 124 | ui.checkbox(&mut payload.show_backtrace, "Show backtrace"); 125 | if payload.show_backtrace { 126 | let bt = bt.to_string(); 127 | egui::ScrollArea::both().max_height(300.0).show(ui, |ui| { 128 | ui.add( 129 | egui::TextEdit::multiline(&mut bt.as_str()) 130 | .code_editor() 131 | .desired_width(f32::INFINITY), 132 | ); 133 | }); 134 | } 135 | }); 136 | } 137 | let (enter_pressed, esc_pressed) = ui.input_mut(|inp| { 138 | ( 139 | // Consume enter and escape, so when the dialog is closed 140 | // using these keys, the normal UI won't receive these keys right away. 141 | // Receiving the keys could for example cause a text parse box 142 | // that parses on enter press to parse again right away with the 143 | // same error when the message box is closed with enter. 144 | inp.consume_key(egui::Modifiers::default(), egui::Key::Enter), 145 | inp.consume_key(egui::Modifiers::default(), egui::Key::Escape), 146 | ) 147 | }); 148 | let mut buttons_ui_fn = payload.buttons_ui_fn.take(); 149 | match &mut buttons_ui_fn { 150 | Some(f) => f(ui, payload, cmd), 151 | None => { 152 | if ui.button("Ok").clicked() || enter_pressed || esc_pressed { 153 | payload.backtrace = None; 154 | close = true; 155 | } 156 | } 157 | } 158 | payload.buttons_ui_fn = buttons_ui_fn; 159 | }); 160 | }); 161 | if close || payload.close { 162 | self.payloads.pop_front(); 163 | } 164 | } 165 | pub fn set_backtrace_for_top(&mut self, bt: Backtrace) { 166 | if let Some(front) = self.payloads.front_mut() { 167 | front.backtrace = Some(bt); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/app/command.rs: -------------------------------------------------------------------------------- 1 | //! Due to various issues with overlapping borrows, it's not always feasible to do every operation 2 | //! on the application state at the time the action is requested. 3 | //! 4 | //! Sometimes we need to wait until we have exclusive access to the application before we can 5 | //! perform an operation. 6 | //! 7 | //! One possible way to do this is to encode whatever data an operation requires, and save it until 8 | //! we have exclusive access, and then perform it. 9 | 10 | use { 11 | super::{App, backend_command::BackendCmd}, 12 | crate::{ 13 | damage_region::DamageRegion, 14 | data::Data, 15 | gui::Gui, 16 | meta::{NamedView, PerspectiveKey, RegionKey}, 17 | scripting::exec_lua, 18 | shell::msg_if_fail, 19 | view::{HexData, View, ViewKind}, 20 | }, 21 | mlua::Lua, 22 | std::{collections::VecDeque, path::Path}, 23 | }; 24 | 25 | pub enum Cmd { 26 | CreatePerspective { 27 | region_key: RegionKey, 28 | name: String, 29 | }, 30 | RemovePerspective(PerspectiveKey), 31 | SetSelection(usize, usize), 32 | SetAndFocusCursor(usize), 33 | SetLayout(crate::meta::LayoutKey), 34 | FocusView(crate::meta::ViewKey), 35 | CreateView { 36 | perspective_key: PerspectiveKey, 37 | name: String, 38 | }, 39 | /// Finish saving a truncated file 40 | SaveTruncateFinish, 41 | /// Extend (or truncate) the data buffer to a new length 42 | ExtendDocument { 43 | new_len: usize, 44 | }, 45 | /// Paste bytes at the requested index 46 | PasteBytes { 47 | at: usize, 48 | bytes: Vec, 49 | }, 50 | /// A new source was loaded, process the changes 51 | ProcessSourceChange, 52 | } 53 | 54 | /// Application command queue. 55 | /// 56 | /// Push operations with `push`, and call `App::flush_command_queue` when you have 57 | /// exclusive access to the `App`. 58 | /// 59 | /// `App::flush_command_queue` is called automatically every frame, if you don't need to perform the operations sooner. 60 | #[derive(Default)] 61 | pub struct CommandQueue { 62 | inner: VecDeque, 63 | } 64 | 65 | impl CommandQueue { 66 | pub fn push(&mut self, command: Cmd) { 67 | self.inner.push_back(command); 68 | } 69 | } 70 | 71 | impl App { 72 | /// Flush the [`CommandQueue`] and perform all operations queued up. 73 | /// 74 | /// Automatically called every frame, but can be called manually if operations need to be 75 | /// performed sooner. 76 | pub fn flush_command_queue( 77 | &mut self, 78 | gui: &mut Gui, 79 | lua: &Lua, 80 | font_size: u16, 81 | line_spacing: u16, 82 | ) { 83 | while let Some(cmd) = self.cmd.inner.pop_front() { 84 | perform_command(self, cmd, gui, lua, font_size, line_spacing); 85 | } 86 | } 87 | } 88 | 89 | /// Perform a command. Called by `App::flush_command_queue`, but can be called manually if you 90 | /// have a `Cmd` you would like you perform. 91 | pub fn perform_command( 92 | app: &mut App, 93 | cmd: Cmd, 94 | gui: &mut Gui, 95 | lua: &Lua, 96 | font_size: u16, 97 | line_spacing: u16, 98 | ) { 99 | match cmd { 100 | Cmd::CreatePerspective { region_key, name } => { 101 | let per_key = app.add_perspective_from_region(region_key, name); 102 | gui.win.perspectives.open.set(true); 103 | gui.win.perspectives.rename_idx = per_key; 104 | } 105 | Cmd::SetSelection(a, b) => { 106 | app.hex_ui.select_a = Some(a); 107 | app.hex_ui.select_b = Some(b); 108 | } 109 | Cmd::SetAndFocusCursor(off) => { 110 | app.edit_state.cursor = off; 111 | app.center_view_on_offset(off); 112 | app.hex_ui.flash_cursor(); 113 | } 114 | Cmd::SetLayout(key) => app.hex_ui.current_layout = key, 115 | Cmd::FocusView(key) => app.hex_ui.focused_view = Some(key), 116 | Cmd::RemovePerspective(key) => { 117 | app.meta_state.meta.low.perspectives.remove(key); 118 | // TODO: Should probably handle dangling keys somehow. 119 | // either by not allowing removal in that case, or being robust against dangling keys 120 | // or removing everything that uses a dangling key. 121 | } 122 | Cmd::CreateView { 123 | perspective_key, 124 | name, 125 | } => { 126 | app.meta_state.meta.views.insert(NamedView { 127 | view: View::new( 128 | ViewKind::Hex(HexData::with_font_size(font_size)), 129 | perspective_key, 130 | ), 131 | name, 132 | }); 133 | } 134 | Cmd::SaveTruncateFinish => { 135 | msg_if_fail( 136 | app.save_truncated_file_finish(), 137 | "Save error", 138 | &mut gui.msg_dialog, 139 | ); 140 | } 141 | Cmd::ExtendDocument { new_len } => { 142 | app.data.resize(new_len, 0); 143 | } 144 | Cmd::PasteBytes { at, bytes } => { 145 | let range = at..at + bytes.len(); 146 | app.data[range.clone()].copy_from_slice(&bytes); 147 | app.data.widen_dirty_region(DamageRegion::Range(range)); 148 | } 149 | Cmd::ProcessSourceChange => { 150 | // Allocate a clean data buffer for streaming sources 151 | if app.source.as_ref().is_some_and(|src| src.attr.stream) { 152 | app.data = Data::clean_from_buf(Vec::new()); 153 | } 154 | app.backend_cmd.push(BackendCmd::SetWindowTitle(format!( 155 | "{} - Hexerator", 156 | app.source_file().map_or("no source", path_filename_as_str) 157 | ))); 158 | if let Some(key) = &app.meta_state.meta.onload_script { 159 | let scr = &app.meta_state.meta.scripts[*key]; 160 | let content = scr.content.clone(); 161 | let result = exec_lua( 162 | lua, 163 | &content, 164 | app, 165 | gui, 166 | "", 167 | Some(*key), 168 | font_size, 169 | line_spacing, 170 | ); 171 | msg_if_fail( 172 | result, 173 | "Failed to execute onload lua script", 174 | &mut gui.msg_dialog, 175 | ); 176 | } 177 | } 178 | } 179 | } 180 | 181 | fn path_filename_as_str(path: &Path) -> &str { 182 | path.file_name() 183 | .map_or("", |osstr| osstr.to_str().unwrap_or_default()) 184 | } 185 | -------------------------------------------------------------------------------- /src/gui/windows/zero_partition.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::{ 4 | gui::egui_ui_ext::EguiResponseExt as _, meta::region::Region, shell::msg_if_fail, 5 | util::human_size, 6 | }, 7 | egui_extras::{Column, TableBuilder}, 8 | }; 9 | 10 | pub struct ZeroPartition { 11 | pub open: WindowOpen, 12 | threshold: usize, 13 | regions: Vec, 14 | reload: bool, 15 | } 16 | 17 | impl Default for ZeroPartition { 18 | fn default() -> Self { 19 | Self { 20 | open: Default::default(), 21 | threshold: 4096, 22 | regions: Default::default(), 23 | reload: false, 24 | } 25 | } 26 | } 27 | 28 | impl super::Window for ZeroPartition { 29 | fn ui(&mut self, WinCtx { ui, app, gui, .. }: WinCtx) { 30 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 31 | ui.horizontal(|ui| { 32 | ui.label("Threshold"); 33 | ui.add(egui::DragValue::new(&mut self.threshold)); 34 | if ui.button("Go").clicked() { 35 | if self.reload { 36 | msg_if_fail(app.reload(), "Failed to reload", &mut gui.msg_dialog); 37 | } 38 | self.regions = zero_partition(&app.data, self.threshold); 39 | } 40 | ui.checkbox(&mut self.reload, "reload") 41 | .on_hover_text("Auto reload data before partitioning"); 42 | if !self.regions.is_empty() { 43 | ui.label(format!("{} results", self.regions.len())); 44 | } 45 | }); 46 | if self.regions.is_empty() { 47 | return; 48 | } 49 | ui.separator(); 50 | TableBuilder::new(ui) 51 | .columns(Column::auto(), 4) 52 | .auto_shrink([false, true]) 53 | .striped(true) 54 | .header(24.0, |mut row| { 55 | row.col(|ui| { 56 | if ui.button("begin").clicked() { 57 | self.regions.sort_by_key(|r| r.begin); 58 | } 59 | }); 60 | row.col(|ui| { 61 | if ui.button("end").clicked() { 62 | self.regions.sort_by_key(|r| r.end); 63 | } 64 | }); 65 | row.col(|ui| { 66 | if ui.button("size").clicked() { 67 | self.regions.sort_by_key(|r| r.len()); 68 | } 69 | }); 70 | }) 71 | .body(|body| { 72 | body.rows(24.0, self.regions.len(), |mut row| { 73 | let reg = &self.regions[row.index()]; 74 | if reg.contains(app.edit_state.cursor) { 75 | row.set_selected(true); 76 | } 77 | row.col(|ui| { 78 | if ui 79 | .link(reg.begin.to_string()) 80 | .on_hover_text_deferred(|| human_size(reg.begin)) 81 | .clicked() 82 | { 83 | app.search_focus(reg.begin); 84 | } 85 | }); 86 | row.col(|ui| { 87 | if ui 88 | .link(reg.end.to_string()) 89 | .on_hover_text_deferred(|| human_size(reg.end)) 90 | .clicked() 91 | { 92 | app.search_focus(reg.end); 93 | } 94 | }); 95 | row.col(|ui| { 96 | ui.label(reg.len().to_string()) 97 | .on_hover_text_deferred(|| human_size(reg.len())); 98 | }); 99 | row.col(|ui| { 100 | if ui.button("Select").clicked() { 101 | app.hex_ui.select_a = Some(reg.begin); 102 | app.hex_ui.select_b = Some(reg.end); 103 | } 104 | }); 105 | }); 106 | }); 107 | } 108 | 109 | fn title(&self) -> &str { 110 | "Zero partition" 111 | } 112 | } 113 | 114 | fn zero_partition(data: &[u8], threshold: usize) -> Vec { 115 | if data.is_empty() { 116 | return Vec::new(); 117 | } 118 | let mut regions = Vec::new(); 119 | let mut reg = Region { begin: 0, end: 0 }; 120 | let mut in_zero = if threshold == 1 { data[0] == 0 } else { false }; 121 | let mut zero_counter = 0; 122 | for (i, &byte) in data.iter().enumerate() { 123 | if byte == 0 { 124 | zero_counter += 1; 125 | if zero_counter == threshold { 126 | if i > threshold && !in_zero { 127 | reg.end = i.saturating_sub(threshold); 128 | regions.push(reg); 129 | } 130 | in_zero = true; 131 | } 132 | } else { 133 | zero_counter = 0; 134 | if in_zero { 135 | in_zero = false; 136 | reg.begin = i; 137 | } 138 | } 139 | } 140 | if !in_zero { 141 | reg.end = data.len() - 1; 142 | regions.push(reg); 143 | } 144 | regions 145 | } 146 | 147 | #[test] 148 | fn test_zero_partition() { 149 | assert_eq!( 150 | zero_partition(&[1, 1, 0, 0, 0, 1, 2, 3], 3), 151 | vec![Region { begin: 0, end: 1 }, Region { begin: 5, end: 7 }] 152 | ); 153 | assert_eq!( 154 | zero_partition(&[1, 1, 0, 0, 0, 1, 2, 3], 4), 155 | vec![Region { begin: 0, end: 7 }] 156 | ); 157 | assert_eq!( 158 | zero_partition( 159 | &[0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1], 160 | 3 161 | ), 162 | vec![ 163 | Region { begin: 0, end: 4 }, 164 | Region { begin: 11, end: 14 }, 165 | Region { begin: 18, end: 18 } 166 | ] 167 | ); 168 | assert_eq!( 169 | zero_partition( 170 | &[0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1], 171 | 1 172 | ), 173 | vec![ 174 | Region { begin: 1, end: 4 }, 175 | Region { begin: 11, end: 14 }, 176 | Region { begin: 18, end: 18 } 177 | ] 178 | ); 179 | // head and tail that exceed threshold 180 | assert_eq!( 181 | zero_partition( 182 | &[ 183 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 0, 0, 4, 5, 6, 0, 0, 0, 0, 0, 0 184 | ], 185 | 4 186 | ), 187 | vec![Region { begin: 10, end: 12 }, Region { begin: 17, end: 19 },] 188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /src/gui/windows/perspectives.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{WinCtx, WindowOpen}, 3 | crate::{ 4 | app::command::Cmd, gui::windows::regions::region_context_menu, meta::PerspectiveKey, 5 | shell::msg_if_fail, 6 | }, 7 | egui_extras::{Column, TableBuilder}, 8 | slotmap::Key as _, 9 | }; 10 | 11 | #[derive(Default)] 12 | pub struct PerspectivesWindow { 13 | pub open: WindowOpen, 14 | pub rename_idx: PerspectiveKey, 15 | } 16 | impl super::Window for PerspectivesWindow { 17 | fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { 18 | ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); 19 | TableBuilder::new(ui) 20 | .columns(Column::auto(), 4) 21 | .column(Column::remainder()) 22 | .striped(true) 23 | .resizable(true) 24 | .header(24.0, |mut row| { 25 | row.col(|ui| { 26 | ui.label("Name"); 27 | }); 28 | row.col(|ui| { 29 | ui.label("Region"); 30 | }); 31 | row.col(|ui| { 32 | ui.label("Columns"); 33 | }); 34 | row.col(|ui| { 35 | ui.label("Rows"); 36 | }); 37 | row.col(|ui| { 38 | ui.label("Flip row order"); 39 | }); 40 | }) 41 | .body(|body| { 42 | let keys: Vec<_> = app.meta_state.meta.low.perspectives.keys().collect(); 43 | body.rows(20.0, keys.len(), |mut row| { 44 | let idx = row.index(); 45 | row.col(|ui| { 46 | if self.rename_idx == keys[idx] { 47 | let re = ui.text_edit_singleline( 48 | &mut app.meta_state.meta.low.perspectives[keys[idx]].name, 49 | ); 50 | if re.lost_focus() { 51 | self.rename_idx = PerspectiveKey::null(); 52 | } else { 53 | re.request_focus(); 54 | } 55 | } else { 56 | let name = &app.meta_state.meta.low.perspectives[keys[idx]].name; 57 | ui.menu_button(name, |ui| { 58 | if ui.button("✏ Rename").clicked() { 59 | self.rename_idx = keys[idx]; 60 | } 61 | if ui.button("🗑 Delete").clicked() { 62 | app.cmd.push(Cmd::RemovePerspective(keys[idx])); 63 | } 64 | if ui.button("Create view").clicked() { 65 | app.cmd.push(Cmd::CreateView { 66 | perspective_key: keys[idx], 67 | name: name.to_owned(), 68 | }); 69 | } 70 | ui.menu_button("Containing views", |ui| { 71 | for (view_key, view) in app.meta_state.meta.views.iter() { 72 | if view.view.perspective == keys[idx] 73 | && ui.button(&view.name).clicked() 74 | { 75 | gui.win.views.open.set(true); 76 | gui.win.views.selected = view_key; 77 | } 78 | } 79 | }); 80 | if ui.button("Copy name to clipboard").clicked() { 81 | let res = app.clipboard.set_text(name); 82 | msg_if_fail( 83 | res, 84 | "Failed to copy to clipboard", 85 | &mut gui.msg_dialog, 86 | ); 87 | } 88 | }); 89 | } 90 | }); 91 | row.col(|ui| { 92 | let per = &app.meta_state.meta.low.perspectives[keys[idx]]; 93 | let reg = &app.meta_state.meta.low.regions[per.region]; 94 | let re = ui.link(®.name).on_hover_text(®.desc); 95 | re.context_menu(|ui| { 96 | region_context_menu( 97 | ui, 98 | reg, 99 | per.region, 100 | &app.meta_state.meta, 101 | &mut app.cmd, 102 | &mut gui.cmd, 103 | ); 104 | }); 105 | if re.clicked() { 106 | gui.win.regions.open.set(true); 107 | gui.win.regions.selected_key = Some(per.region); 108 | } 109 | }); 110 | row.col(|ui| { 111 | let per = &mut app.meta_state.meta.low.perspectives[keys[idx]]; 112 | let reg = &app.meta_state.meta.low.regions[per.region]; 113 | ui.add(egui::DragValue::new(&mut per.cols).range(1..=reg.region.len())); 114 | }); 115 | row.col(|ui| { 116 | let per = &app.meta_state.meta.low.perspectives[keys[idx]]; 117 | let reg = &app.meta_state.meta.low.regions[per.region]; 118 | let reg_len = reg.region.len(); 119 | let cols = per.cols; 120 | let rows = reg_len / cols; 121 | let rem = reg_len % cols; 122 | let rem_str: &str = if rem == 0 { 123 | "" 124 | } else { 125 | &format!(" (rem: {rem})") 126 | }; 127 | ui.label(format!("{rows}{rem_str}")); 128 | }); 129 | row.col(|ui| { 130 | ui.checkbox( 131 | &mut app.meta_state.meta.low.perspectives[keys[idx]].flip_row_order, 132 | "", 133 | ); 134 | }); 135 | }); 136 | }); 137 | ui.separator(); 138 | ui.menu_button("New from region", |ui| { 139 | for (key, region) in app.meta_state.meta.low.regions.iter() { 140 | if ui.button(®ion.name).clicked() { 141 | app.cmd.push(Cmd::CreatePerspective { 142 | region_key: key, 143 | name: region.name.clone(), 144 | }); 145 | 146 | return; 147 | } 148 | } 149 | }); 150 | } 151 | 152 | fn title(&self) -> &str { 153 | "Perspectives" 154 | } 155 | } 156 | --------------------------------------------------------------------------------