diff --git a/objdiff-cli/src/cmd/diff.rs b/objdiff-cli/src/cmd/diff.rs index 4e35b3f..096cd8a 100644 --- a/objdiff-cli/src/cmd/diff.rs +++ b/objdiff-cli/src/cmd/diff.rs @@ -337,6 +337,7 @@ impl AppState { JobResult::CheckUpdate(_) => todo!("CheckUpdate"), JobResult::Update(_) => todo!("Update"), JobResult::CreateScratch(_) => todo!("CreateScratch"), + JobResult::FindSimilar(_) => unreachable!("Unexpected JobResult::FindSimilar"), } } Ok(redraw) diff --git a/objdiff-core/src/diff/mod.rs b/objdiff-core/src/diff/mod.rs index e0aa91b..2e64125 100644 --- a/objdiff-core/src/diff/mod.rs +++ b/objdiff-core/src/diff/mod.rs @@ -419,6 +419,49 @@ pub fn diff_objs( }) } +/// Score entry for a candidate symbol when searching for similar functions. +#[derive(Debug, Clone)] +pub struct SimilarSymbol { + pub symbol_idx: usize, + pub match_percent: f32, +} + +/// Find all code symbols in `target_obj` that are similar to a given symbol in `source_obj`, +/// sorted descending by similarity score. Symbols that fail to score are silently skipped. +pub fn find_similar_code_symbols( + source_obj: &Object, + source_symbol_idx: usize, + target_obj: &Object, + diff_config: &DiffObjConfig, +) -> Vec { + let source_symbol = &source_obj.symbols[source_symbol_idx]; + let source_section_kind = symbol_section_kind(source_obj, source_symbol); + if source_section_kind != SectionKind::Code { + return vec![]; + } + + let mut results = Vec::new(); + for (target_idx, target_symbol) in target_obj.symbols.iter().enumerate() { + if target_symbol.size == 0 || target_symbol.flags.contains(SymbolFlag::Ignored) { + continue; + } + if symbol_section_kind(target_obj, target_symbol) != SectionKind::Code { + continue; + } + let Ok((left_diff, _)) = + diff_code(source_obj, target_obj, source_symbol_idx, target_idx, diff_config) + else { + continue; + }; + let Some(match_percent) = left_diff.match_percent else { continue }; + results.push(SimilarSymbol { symbol_idx: target_idx, match_percent }); + } + results.sort_by(|a, b| { + b.match_percent.partial_cmp(&a.match_percent).unwrap_or(core::cmp::Ordering::Equal) + }); + results +} + #[derive(Clone, Copy)] enum MappingSymbol<'a> { Left(&'a str), diff --git a/objdiff-core/src/jobs/find_similar.rs b/objdiff-core/src/jobs/find_similar.rs new file mode 100644 index 0000000..4ed73d5 --- /dev/null +++ b/objdiff-core/src/jobs/find_similar.rs @@ -0,0 +1,181 @@ +use std::{sync::mpsc::Receiver, task::Waker}; + +use anyhow::Result; +use typed_path::Utf8PlatformPathBuf; + +use crate::{ + build::{BuildConfig, run_make}, + diff::{DiffObjConfig, DiffSide, display::InstructionPart, find_similar_code_symbols}, + jobs::{Job, JobContext, JobResult, JobState, start_job, update_status}, + obj::{InstructionArg, read}, +}; + +pub struct FindSimilarConfig { + /// Path to the source object file to search for. + pub source_path: Utf8PlatformPathBuf, + /// Mangled name of the source symbol. + pub source_symbol_name: String, + /// 0 = left/target column, 1 = right/base column. + pub source_column: usize, + /// All project objects to scan. + pub objects: Vec, + pub diff_config: DiffObjConfig, + pub build_config: BuildConfig, + pub build_base: bool, + pub build_target: bool, +} + +pub struct ScanObject { + pub name: String, + pub target_path: Option, + pub base_path: Option, +} + +#[derive(Debug, Clone)] +pub struct SimilarFunctionMatch { + pub symbol_name: String, + pub demangled_name: Option, + pub match_percent: f32, + /// Human-readable identifier: "ObjectName (target)" or "ObjectName (base)". + pub object_name: String, +} + +pub struct FindSimilarResult { + pub source_symbol_name: String, + pub source_column: usize, + pub matches: Vec, +} + +fn run_find_similar( + context: &JobContext, + cancel: Receiver<()>, + config: FindSimilarConfig, +) -> Result> { + let diff_side = if config.source_column == 0 { DiffSide::Target } else { DiffSide::Base }; + let source_obj = read::read(config.source_path.as_ref(), &config.diff_config, diff_side)?; + let source_symbol_idx = + source_obj.symbol_by_name(&config.source_symbol_name).ok_or_else(|| { + anyhow::anyhow!("Source symbol '{}' not found", config.source_symbol_name) + })?; + + // Print the instructions of the source symbol to the console. + 'print: { + let symbol = &source_obj.symbols[source_symbol_idx]; + let Some(section_index) = symbol.section else { break 'print }; + let section = &source_obj.sections[section_index]; + let Some(data) = section.data_range(symbol.address, symbol.size as usize) else { + break 'print; + }; + let Ok(instructions) = source_obj.arch.scan_instructions( + crate::obj::ResolvedSymbol { + obj: &source_obj, + symbol_index: source_symbol_idx, + symbol, + section_index, + section, + data, + }, + &config.diff_config, + ) else { + break 'print; + }; + log::info!( + "find_similar: source symbol '{}' — {} instructions", + config.source_symbol_name, + instructions.len() + ); + for ins_ref in &instructions { + let Some(resolved) = source_obj.resolve_instruction_ref(source_symbol_idx, *ins_ref) + else { + continue; + }; + let mut text = format!("{:#010x} ", ins_ref.address); + let _ = + source_obj.arch.display_instruction(resolved, &config.diff_config, &mut |part| { + match part { + InstructionPart::Basic(s) | InstructionPart::Opcode(s, _) => { + text.push_str(&s) + } + InstructionPart::Arg(InstructionArg::Value(v)) => { + text.push_str(&v.to_string()) + } + InstructionPart::Arg(InstructionArg::BranchDest(addr)) => { + text.push_str(&format!("{addr:#x}")) + } + InstructionPart::Arg(InstructionArg::Reloc) => { + if let Some(reloc) = resolved.relocation { + let sym = &source_obj.symbols[reloc.relocation.target_symbol]; + text.push_str(sym.demangled_name.as_deref().unwrap_or(&sym.name)); + if reloc.relocation.addend != 0 { + text.push_str(&format!("+{:#x}", reloc.relocation.addend)); + } + } else { + text.push_str(""); + } + } + InstructionPart::Separator => text.push_str(", "), + } + Ok(()) + }); + log::info!("{text}"); + } + } + + let total = config.objects.len() as u32; + let mut all_matches = Vec::new(); + + for (idx, scan_obj) in config.objects.iter().enumerate() { + update_status(context, format!("Scanning {}", scan_obj.name), idx as u32, total, &cancel)?; + + let project_dir = config.build_config.project_dir.as_deref(); + + for side in [DiffSide::Target, DiffSide::Base] { + let (path, should_build) = match side { + DiffSide::Target => (scan_obj.target_path.as_ref(), config.build_target), + DiffSide::Base => (scan_obj.base_path.as_ref(), config.build_base), + }; + let Some(path) = path else { continue }; + + if should_build + && let Some(project_dir) = project_dir + && let Ok(rel_path) = path.strip_prefix(project_dir) + { + run_make(&config.build_config, rel_path.with_unix_encoding().as_ref()); + } + + let Ok(obj) = read::read(path.as_ref(), &config.diff_config, side) else { continue }; + let similar = find_similar_code_symbols( + &source_obj, + source_symbol_idx, + &obj, + &config.diff_config, + ); + let side_label = if side == DiffSide::Target { "target" } else { "base" }; + for sim in similar { + let symbol = &obj.symbols[sim.symbol_idx]; + all_matches.push(SimilarFunctionMatch { + symbol_name: symbol.name.clone(), + demangled_name: symbol.demangled_name.clone(), + match_percent: sim.match_percent, + object_name: format!("{} ({})", scan_obj.name, side_label), + }); + } + } + } + + all_matches.sort_by(|a, b| { + b.match_percent.partial_cmp(&a.match_percent).unwrap_or(std::cmp::Ordering::Equal) + }); + + Ok(Box::new(FindSimilarResult { + source_symbol_name: config.source_symbol_name, + source_column: config.source_column, + matches: all_matches, + })) +} + +pub fn start_find_similar(waker: Waker, config: FindSimilarConfig) -> JobState { + start_job(waker, "Find similar functions", Job::FindSimilar, move |context, cancel| { + run_find_similar(&context, cancel, config).map(|r| JobResult::FindSimilar(Some(r))) + }) +} diff --git a/objdiff-core/src/jobs/mod.rs b/objdiff-core/src/jobs/mod.rs index 4344044..a4aa440 100644 --- a/objdiff-core/src/jobs/mod.rs +++ b/objdiff-core/src/jobs/mod.rs @@ -11,12 +11,13 @@ use std::{ use anyhow::Result; use crate::jobs::{ - check_update::CheckUpdateResult, create_scratch::CreateScratchResult, objdiff::ObjDiffResult, - update::UpdateResult, + check_update::CheckUpdateResult, create_scratch::CreateScratchResult, + find_similar::FindSimilarResult, objdiff::ObjDiffResult, update::UpdateResult, }; pub mod check_update; pub mod create_scratch; +pub mod find_similar; pub mod objdiff; pub mod update; @@ -26,6 +27,7 @@ pub enum Job { CheckUpdate, Update, CreateScratch, + FindSimilar, } pub static JOB_ID: AtomicUsize = AtomicUsize::new(0); @@ -168,6 +170,7 @@ pub enum JobResult { CheckUpdate(Option>), Update(Box), CreateScratch(Option>), + FindSimilar(Option>), } fn start_job( diff --git a/objdiff-gui/src/app.rs b/objdiff-gui/src/app.rs index 71bdaa2..be32d63 100644 --- a/objdiff-gui/src/app.rs +++ b/objdiff-gui/src/app.rs @@ -717,7 +717,8 @@ impl eframe::App for App { frame_history.on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage); - let side_panel_available = diff_state.current_view == View::SymbolDiff; + let side_panel_available = + diff_state.current_view == View::SymbolDiff && diff_state.similar_functions.is_none(); egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { // Temporarily use pre-egui 0.32 menu. ComboBox within menu diff --git a/objdiff-gui/src/jobs.rs b/objdiff-gui/src/jobs.rs index bbaf056..3c0e60e 100644 --- a/objdiff-gui/src/jobs.rs +++ b/objdiff-gui/src/jobs.rs @@ -4,7 +4,7 @@ use std::{ }; use anyhow::{Result, bail}; -use jobs::create_scratch; +use jobs::{create_scratch, find_similar}; use objdiff_core::{ build::BuildConfig, diff::MappingConfig, @@ -126,6 +126,39 @@ pub fn start_build(ctx: &egui::Context, jobs: &mut JobQueue, config: objdiff::Ob jobs.push_once(Job::ObjDiff, || objdiff::start_build(egui_waker(ctx), config)); } +pub fn start_find_similar_job( + ctx: &egui::Context, + jobs: &mut JobQueue, + state: &AppState, + source_symbol_name: String, + column: usize, +) { + let Some(selected_obj) = &state.config.selected_obj else { return }; + let source_path = + if column == 0 { selected_obj.target_path.clone() } else { selected_obj.base_path.clone() }; + let Some(source_path) = source_path else { return }; + let objects = state + .objects + .iter() + .map(|o| find_similar::ScanObject { + name: o.name.clone(), + target_path: o.target_path.clone(), + base_path: o.base_path.clone(), + }) + .collect(); + let config = find_similar::FindSimilarConfig { + source_path, + source_symbol_name, + source_column: column, + objects, + diff_config: state.effective_diff_config(), + build_config: BuildConfig::from(&state.config), + build_base: state.config.build_base, + build_target: state.config.build_target, + }; + jobs.push_once(Job::FindSimilar, || find_similar::start_find_similar(egui_waker(ctx), config)); +} + pub fn start_check_update(ctx: &egui::Context, jobs: &mut JobQueue) { jobs.push_once(Job::Update, || { jobs::check_update::start_check_update(egui_waker(ctx), CheckUpdateConfig { diff --git a/objdiff-gui/src/views/diff.rs b/objdiff-gui/src/views/diff.rs index 782f102..0bbb02a 100644 --- a/objdiff-gui/src/views/diff.rs +++ b/objdiff-gui/src/views/diff.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use egui::{Id, Layout, RichText, ScrollArea, TextEdit, Ui, Widget, text::LayoutJob}; +use egui::{Id, Layout, RichText, ScrollArea, Slider, TextEdit, Ui, Widget, text::LayoutJob}; use objdiff_core::{ build::BuildStatus, diff::{ @@ -26,7 +26,8 @@ use crate::{ function_diff::{FunctionDiffContext, asm_col_ui}, symbol_diff::{ DiffViewAction, DiffViewNavigation, DiffViewState, SymbolDiffContext, SymbolRefByName, - View, match_color_for_symbol, symbol_context_menu_ui, symbol_hover_ui, symbol_list_ui, + View, match_color_for_symbol, similar_functions_col_ui, symbol_context_menu_ui, + symbol_hover_ui, symbol_list_ui, }, write_text, }, @@ -188,6 +189,68 @@ pub fn diff_view_ui( let mut scroll_to_prev_diff = false; let mut scroll_to_next_diff = false; + // Full-width similar functions panel: skip the normal two-column layout entirely. + if let Some(sim) = &state.similar_functions { + render_header(ui, available_width, 1, |ui, _| { + ui.horizontal(|ui| { + if ui.button("⏴ Back").clicked() || hotkeys::back_pressed(ui.ctx()) { + ret = Some(DiffViewAction::CloseSimilarFunctions); + } + }); + ui.label( + RichText::new(format!("Similar to {}", sim.source_symbol_name)) + .font(appearance.code_font.clone()) + .color(appearance.highlight_color), + ); + ui.horizontal(|ui| { + let mut search = sim.search.clone(); + let response = TextEdit::singleline(&mut search).hint_text("Filter symbols").ui(ui); + if hotkeys::consume_symbol_filter_shortcut(ui.ctx()) { + response.request_focus(); + } + if response.changed() { + ret = Some(DiffViewAction::SetSimilarSearch(search)); + } + let mut show_target = sim.show_target; + if ui.checkbox(&mut show_target, "Target").changed() { + ret = Some(DiffViewAction::SetSimilarShowTarget(show_target)); + } + let mut show_base = sim.show_base; + if ui.checkbox(&mut show_base, "Base").changed() { + ret = Some(DiffViewAction::SetSimilarShowBase(show_base)); + } + }); + ui.horizontal(|ui| { + ui.label("Min:"); + let mut min = sim.min_percent; + if ui + .add(Slider::new(&mut min, 0.0..=sim.max_percent).suffix("%").fixed_decimals(0)) + .changed() + { + ret = + Some(DiffViewAction::SetSimilarPercentRange { min, max: sim.max_percent }); + } + ui.label("Max:"); + let mut max = sim.max_percent; + if ui + .add( + Slider::new(&mut max, sim.min_percent..=100.0) + .suffix("%") + .fixed_decimals(0), + ) + .changed() + { + ret = + Some(DiffViewAction::SetSimilarPercentRange { min: sim.min_percent, max }); + } + }); + }); + ui.push_id("similar_functions", |ui| { + let _ = similar_functions_col_ui(ui, sim, appearance); + }); + return ret; + } + render_header(ui, available_width, 2, |ui, column| { if column == 0 { // Left column diff --git a/objdiff-gui/src/views/symbol_diff.rs b/objdiff-gui/src/views/symbol_diff.rs index 0260fd7..952ea3b 100644 --- a/objdiff-gui/src/views/symbol_diff.rs +++ b/objdiff-gui/src/views/symbol_diff.rs @@ -4,6 +4,7 @@ use egui::{ CollapsingHeader, Color32, Id, OpenUrl, ScrollArea, Ui, Widget, style::ScrollAnimation, text::LayoutJob, }; +use egui_extras::{Column, TableBuilder}; use objdiff_core::{ diff::{ DiffObjConfig, ObjectDiff, ShowSymbolSizes, SymbolDiff, @@ -12,7 +13,10 @@ use objdiff_core::{ symbol_context, symbol_hover, }, }, - jobs::{Job, JobQueue, JobResult, create_scratch::CreateScratchResult, objdiff::ObjDiffResult}, + jobs::{ + Job, JobQueue, JobResult, create_scratch::CreateScratchResult, + find_similar::SimilarFunctionMatch, objdiff::ObjDiffResult, + }, obj::{Object, Section, SectionKind, Symbol, SymbolFlag}, }; use regex::{Regex, RegexBuilder}; @@ -20,7 +24,7 @@ use regex::{Regex, RegexBuilder}; use crate::{ app::AppStateRef, hotkeys, - jobs::{is_create_scratch_available, start_create_scratch}, + jobs::{is_create_scratch_available, start_create_scratch, start_find_similar_job}, views::{ appearance::Appearance, diff::{context_menu_items_ui, hover_items_ui}, @@ -83,6 +87,25 @@ pub enum DiffViewAction { SetShowDataFlow(bool), // Scrolls a row of the function view table into view. ScrollToRow(usize), + /// Find and display code symbols similar to the given symbol. + /// `column` is 0 for left object, 1 for right object. + FindSimilarFunctions { + symbol_idx: usize, + column: usize, + }, + /// Close the similar functions panel. + CloseSimilarFunctions, + /// Set the similar functions search filter. + SetSimilarSearch(String), + /// Show/hide target-side results in the similar functions panel. + SetSimilarShowTarget(bool), + /// Show/hide base-side results in the similar functions panel. + SetSimilarShowBase(bool), + /// Set the minimum/maximum match-percent filter for the similar functions panel. + SetSimilarPercentRange { + min: f32, + max: f32, + }, } #[derive(Debug, Clone, Default, Eq, PartialEq)] @@ -109,6 +132,19 @@ pub struct ResolvedNavigation { pub right_symbol: Option, } +pub struct SimilarFunctionsState { + /// Display name (demangled) of the source symbol + pub source_symbol_name: String, + /// `None` while the job is running; `Some` once complete + pub matches: Option>, + pub search: String, + pub search_regex: Option, + pub show_target: bool, + pub show_base: bool, + pub min_percent: f32, + pub max_percent: f32, +} + #[derive(Default)] pub struct DiffViewState { pub build: Option>, @@ -127,6 +163,7 @@ pub struct DiffViewState { pub source_path_available: bool, pub post_build_nav: Option, pub object_name: String, + pub similar_functions: Option, } #[derive(Default)] @@ -168,6 +205,14 @@ impl DiffViewState { self.scratch = take(result); false } + JobResult::FindSimilar(result) => { + if let Some(result) = take(result) + && let Some(state) = &mut self.similar_functions + { + state.matches = Some(result.matches); + } + false + } _ => true, }); self.build_running = jobs.is_running(Job::ObjDiff); @@ -369,6 +414,62 @@ impl DiffViewState { DiffViewAction::ScrollToRow(row) => { self.scroll_to_diff_row = Some(row); } + DiffViewAction::CloseSimilarFunctions => { + self.similar_functions = None; + } + DiffViewAction::SetSimilarSearch(search) => { + if let Some(state) = &mut self.similar_functions { + state.search_regex = if search.is_empty() { + None + } else { + RegexBuilder::new(&search).case_insensitive(true).build().ok() + }; + state.search = search; + } + } + DiffViewAction::SetSimilarShowTarget(value) => { + if let Some(state) = &mut self.similar_functions { + state.show_target = value; + } + } + DiffViewAction::SetSimilarShowBase(value) => { + if let Some(state) = &mut self.similar_functions { + state.show_base = value; + } + } + DiffViewAction::SetSimilarPercentRange { min, max } => { + if let Some(state) = &mut self.similar_functions { + state.min_percent = min; + state.max_percent = max; + } + } + DiffViewAction::FindSimilarFunctions { symbol_idx, column } => { + let Some(result) = self.build.as_deref() else { return }; + let Some((source_obj, _)) = (match column { + 0 => result.first_obj.as_ref(), + _ => result.second_obj.as_ref(), + }) else { + return; + }; + let Some(source_symbol) = source_obj.symbols.get(symbol_idx) else { return }; + let source_symbol_name = source_symbol.name.clone(); + let display_name = source_symbol + .demangled_name + .clone() + .unwrap_or_else(|| source_symbol_name.clone()); + self.similar_functions = Some(SimilarFunctionsState { + source_symbol_name: display_name, + matches: None, + search: String::new(), + search_regex: None, + show_target: true, + show_base: true, + min_percent: 0.0, + max_percent: 100.0, + }); + let Ok(state_guard) = state.read() else { return }; + start_find_similar_job(ctx, jobs, &state_guard, source_symbol_name, column); + } } } @@ -527,6 +628,13 @@ pub fn symbol_context_menu_ui( } ui.close(); } + + if section.is_some_and(|s| s.kind == SectionKind::Code) + && ui.button("Find similar functions").clicked() + { + ret = Some(DiffViewAction::FindSimilarFunctions { symbol_idx, column }); + ui.close(); + } }); ret } @@ -838,6 +946,100 @@ pub fn symbol_list_ui( ret } +/// Render the "Find Similar Functions" results in a column body, +/// matching the style of [`symbol_list_ui`]. +#[must_use] +pub fn similar_functions_col_ui( + ui: &mut Ui, + state: &SimilarFunctionsState, + appearance: &Appearance, +) -> Option { + ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace); + match &state.matches { + None => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Scanning project objects…"); + }); + } + Some(matches) if matches.is_empty() => { + ui.label("No similar functions found."); + } + Some(matches) => { + let filtered: Vec<&SimilarFunctionMatch> = matches + .iter() + .filter(|m| { + let is_base = m.object_name.ends_with(" (base)"); + let is_target = m.object_name.ends_with(" (target)"); + if is_target && !state.show_target { + return false; + } + if is_base && !state.show_base { + return false; + } + if m.match_percent < state.min_percent || m.match_percent > state.max_percent { + return false; + } + if let Some(re) = &state.search_regex { + let name = m.demangled_name.as_deref().unwrap_or(&m.symbol_name); + if !re.is_match(name) && !re.is_match(&m.object_name) { + return false; + } + } + true + }) + .collect(); + + let row_height = appearance.code_font.size; + let available_height = ui.available_height(); + TableBuilder::new(ui) + .auto_shrink([false, false]) + .min_scrolled_height(available_height) + .resizable(true) + .column(Column::initial(40.0).at_least(40.0).clip(true)) + .column(Column::remainder().at_least(500.0).clip(true)) + .column(Column::initial(260.0).at_least(260.0).clip(true)) + .body(|body| { + body.rows(row_height, filtered.len(), |mut row| { + let m = filtered[row.index()]; + let name = m.demangled_name.as_deref().unwrap_or(&m.symbol_name); + let (base_name, suffix) = + if let Some(n) = m.object_name.strip_suffix(" (target)") { + (n, " (target)") + } else if let Some(n) = m.object_name.strip_suffix(" (base)") { + (n, " (base)") + } else { + (m.object_name.as_str(), "") + }; + let file_name = std::path::Path::new(base_name) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(base_name); + row.col(|ui| { + ui.label( + egui::RichText::new(format!("{:.0}%", m.match_percent.floor())) + .color(match_color_for_symbol(m.match_percent, appearance)), + ); + }); + row.col(|ui| { + ui.label(name); + }); + row.col(|ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.weak(format!("{file_name}{suffix}")) + .on_hover_text(&m.object_name); + }, + ); + }); + }); + }); + } + } + None +} + #[derive(Copy, Clone)] pub struct SymbolDiffContext<'a> { pub obj: &'a Object,