Repository: encounter/objdiff (language: rust)

Issue:
Allow comparing functions with differing names
In cases where symbols maps are not or only partially available, it is not easily possible to compare the target against the base function:

![image](https://github.com/user-attachments/assets/c92abced-f485-41eb-82e4-a27ead022972)

It would be nice if, when clicking on a function that doesn't have a corresponding symbol, you could select the function to compare against. (Ideally this could be saved in the objdiff.json for persistence)

Even better, objdiff could maybe even try to automatically find the corresponding symbol by looking at the function order.


Discussion / hints:
This would be especially good for when you need to often change parameters to see the effect!

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 13c69f7..f903bda 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -395,6 +395,15 @@ version = "0.22.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
 
+[[package]]
+name = "bimap"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "bit-set"
 version = "0.6.0"
@@ -2852,7 +2861,7 @@ dependencies = [
 
 [[package]]
 name = "objdiff-cli"
-version = "2.2.2"
+version = "2.3.0"
 dependencies = [
  "anyhow",
  "argp",
@@ -2874,10 +2883,11 @@ dependencies = [
 
 [[package]]
 name = "objdiff-core"
-version = "2.2.2"
+version = "2.3.0"
 dependencies = [
  "anyhow",
  "arm-attr",
+ "bimap",
  "byteorder",
  "console_error_panic_hook",
  "console_log",
@@ -2913,7 +2923,7 @@ dependencies = [
 
 [[package]]
 name = "objdiff-gui"
-version = "2.2.2"
+version = "2.3.0"
 dependencies = [
  "anyhow",
  "bytes",
diff --git a/Cargo.toml b/Cargo.toml
index b395ae2..98a6055 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ strip = "debuginfo"
 codegen-units = 1
 
 [workspace.package]
-version = "2.2.2"
+version = "2.3.0"
 authors = ["Luke Street <luke@street.dev>"]
 edition = "2021"
 license = "MIT OR Apache-2.0"
diff --git a/config.schema.json b/config.schema.json
index 41addf3..eaec9fa 100644
--- a/config.schema.json
+++ b/config.schema.json
@@ -133,6 +133,13 @@
         },
         "metadata": {
           "ref": "#/$defs/metadata"
+        },
+        "symbol_mappings": {
+          "type": "object",
+          "description": "Manual symbol mappings from target to base.",
+          "additionalProperties": {
+            "type": "string"
+          }
         }
       }
     },
diff --git a/objdiff-cli/src/cmd/diff.rs b/objdiff-cli/src/cmd/diff.rs
index d8530f7..c24dff4 100644
--- a/objdiff-cli/src/cmd/diff.rs
+++ b/objdiff-cli/src/cmd/diff.rs
@@ -102,26 +102,32 @@ pub fn run(args: Args) -> Result<()> {
                     let unit_path =
                         PathBuf::from_str(u).ok().and_then(|p| fs::canonicalize(p).ok());
 
-                    let Some(object) = project_config.objects.iter_mut().find_map(|obj| {
-                        if obj.name.as_deref() == Some(u) {
-                            resolve_paths(obj);
-                            return Some(obj);
-                        }
+                    let Some(object) = project_config
+                        .units
+                        .as_deref_mut()
+                        .unwrap_or_default()
+                        .iter_mut()
+                        .find_map(|obj| {
+                            if obj.name.as_deref() == Some(u) {
+                                resolve_paths(obj);
+                                return Some(obj);
+                            }
 
-                        let up = unit_path.as_deref()?;
+                            let up = unit_path.as_deref()?;
 
-                        resolve_paths(obj);
+                            resolve_paths(obj);
 
-                        if [&obj.base_path, &obj.target_path]
-                            .into_iter()
-                            .filter_map(|p| p.as_ref().and_then(|p| p.canonicalize().ok()))
-                            .any(|p| p == up)
-                        {
-                            return Some(obj);
-                        }
+                            if [&obj.base_path, &obj.target_path]
+                                .into_iter()
+                                .filter_map(|p| p.as_ref().and_then(|p| p.canonicalize().ok()))
+                                .any(|p| p == up)
+                            {
+                                return Some(obj);
+                            }
 
-                        None
-                    }) else {
+                            None
+                        })
+                    else {
                         bail!("Unit not found: {}", u)
                     };
 
@@ -129,7 +135,13 @@ pub fn run(args: Args) -> Result<()> {
                 } else if let Some(symbol_name) = &args.symbol {
                     let mut idx = None;
                     let mut count = 0usize;
-                    for (i, obj) in project_config.objects.iter_mut().enumerate() {
+                    for (i, obj) in project_config
+                        .units
+                        .as_deref_mut()
+                        .unwrap_or_default()
+                        .iter_mut()
+                        .enumerate()
+                    {
                         resolve_paths(obj);
 
                         if obj
@@ -148,7 +160,7 @@ pub fn run(args: Args) -> Result<()> {
                     }
                     match (count, idx) {
                         (0, None) => bail!("Symbol not found: {}", symbol_name),
-                        (1, Some(i)) => &mut project_config.objects[i],
+                        (1, Some(i)) => &mut project_config.units_mut()[i],
                         (2.., Some(_)) => bail!(
                             "Multiple instances of {} were found, try specifying a unit",
                             symbol_name
@@ -303,7 +315,7 @@ fn find_function(obj: &ObjInfo, name: &str) -> Option<SymbolRef> {
     None
 }
 
-#[allow(dead_code)]
+#[expect(dead_code)]
 struct FunctionDiffUi {
     relax_reloc_diffs: bool,
     left_highlight: HighlightKind,
@@ -758,7 +770,7 @@ impl FunctionDiffUi {
         self.scroll_y += self.per_page / if half { 2 } else { 1 };
     }
 
-    #[allow(clippy::too_many_arguments)]
+    #[expect(clippy::too_many_arguments)]
     fn print_sym(
         &self,
         out: &mut Text<'static>,
diff --git a/objdiff-cli/src/cmd/report.rs b/objdiff-cli/src/cmd/report.rs
index 169e3da..68e6c28 100644
--- a/objdiff-cli/src/cmd/report.rs
+++ b/objdiff-cli/src/cmd/report.rs
@@ -94,7 +94,7 @@ fn generate(args: GenerateArgs) -> Result<()> {
     };
     info!(
         "Generating report for {} units (using {} threads)",
-        project.objects.len(),
+        project.units().len(),
         if args.deduplicate { 1 } else { rayon::current_num_threads() }
     );
 
@@ -103,7 +103,7 @@ fn generate(args: GenerateArgs) -> Result<()> {
     let mut existing_functions: HashSet<String> = HashSet::new();
     if args.deduplicate {
         // If deduplicating, we need to run single-threaded
-        for object in &mut project.objects {
+        for object in project.units.as_deref_mut().unwrap_or_default() {
             if let Some(unit) = report_object(
                 object,
                 project_dir,
@@ -116,7 +116,9 @@ fn generate(args: GenerateArgs) -> Result<()> {
         }
     } else {
         let vec = project
-            .objects
+            .units
+            .as_deref_mut()
+            .unwrap_or_default()
             .par_iter_mut()
             .map(|object| {
                 report_object(
@@ -132,7 +134,7 @@ fn generate(args: GenerateArgs) -> Result<()> {
     }
     let measures = units.iter().flat_map(|u| u.measures.into_iter()).collect();
     let mut categories = Vec::new();
-    for category in &project.progress_categories {
+    for category in project.progress_categories() {
         categories.push(ReportCategory {
             id: category.id.clone(),
             name: category.name.clone(),
diff --git a/objdiff-core/Cargo.toml b/objdiff-core/Cargo.toml
index b1dd465..7b4deb9 100644
--- a/objdiff-core/Cargo.toml
+++ b/objdiff-core/Cargo.toml
@@ -17,8 +17,8 @@ crate-type = ["cdylib", "rlib"]
 
 [features]
 all = ["config", "dwarf", "mips", "ppc", "x86", "arm", "bindings"]
-any-arch = [] # Implicit, used to check if any arch is enabled
-config = ["globset", "semver", "serde_json", "serde_yaml"]
+any-arch = ["bimap"] # Implicit, used to check if any arch is enabled
+config = ["bimap", "globset", "semver", "serde_json", "serde_yaml"]
 dwarf = ["gimli"]
 mips = ["any-arch", "rabbitizer"]
 ppc = ["any-arch", "cwdemangle", "cwextab", "ppc750cl"]
@@ -32,6 +32,7 @@ features = ["all"]
 
 [dependencies]
 anyhow = "1.0"
+bimap = { version = "0.6", features = ["serde"], optional = true }
 byteorder = "1.5"
 filetime = "0.2"
 flagset = "0.4"
diff --git a/objdiff-core/src/config/mod.rs b/objdiff-core/src/config/mod.rs
index c8826d4..09d064b 100644
--- a/objdiff-core/src/config/mod.rs
+++ b/objdiff-core/src/config/mod.rs
@@ -1,77 +1,100 @@
 use std::{
+    fs,
     fs::File,
-    io::{BufReader, Read},
+    io::{BufReader, BufWriter, Read},
     path::{Path, PathBuf},
 };
 
 use anyhow::{anyhow, Context, Result};
+use bimap::BiBTreeMap;
 use filetime::FileTime;
 use globset::{Glob, GlobSet, GlobSetBuilder};
 
-#[inline]
-fn bool_true() -> bool { true }
-
-#[derive(Default, Clone, serde::Deserialize)]
+#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
 pub struct ProjectConfig {
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub min_version: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub custom_make: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub custom_args: Option<Vec<String>>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub target_dir: Option<PathBuf>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub base_dir: Option<PathBuf>,
-    #[serde(default = "bool_true")]
-    pub build_base: bool,
-    #[serde(default)]
-    pub build_target: bool,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub build_base: Option<bool>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub build_target: Option<bool>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub watch_patterns: Option<Vec<Glob>>,
-    #[serde(default, alias = "units")]
-    pub objects: Vec<ProjectObject>,
-    #[serde(default)]
-    pub progress_categories: Vec<ProjectProgressCategory>,
+    #[serde(default, alias = "objects", skip_serializing_if = "Option::is_none")]
+    pub units: Option<Vec<ProjectObject>>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub progress_categories: Option<Vec<ProjectProgressCategory>>,
+}
+
+impl ProjectConfig {
+    #[inline]
+    pub fn units(&self) -> &[ProjectObject] { self.units.as_deref().unwrap_or_default() }
+
+    #[inline]
+    pub fn units_mut(&mut self) -> &mut Vec<ProjectObject> {
+        self.units.get_or_insert_with(Vec::new)
+    }
+
+    #[inline]
+    pub fn progress_categories(&self) -> &[ProjectProgressCategory] {
+        self.progress_categories.as_deref().unwrap_or_default()
+    }
+
+    #[inline]
+    pub fn progress_categories_mut(&mut self) -> &mut Vec<ProjectProgressCategory> {
+        self.progress_categories.get_or_insert_with(Vec::new)
+    }
 }
 
-#[derive(Default, Clone, serde::Deserialize)]
+#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
 pub struct ProjectObject {
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub name: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub path: Option<PathBuf>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub target_path: Option<PathBuf>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub base_path: Option<PathBuf>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     #[deprecated(note = "Use metadata.reverse_fn_order")]
     pub reverse_fn_order: Option<bool>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     #[deprecated(note = "Use metadata.complete")]
     pub complete: Option<bool>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub scratch: Option<ScratchConfig>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub metadata: Option<ProjectObjectMetadata>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub symbol_mappings: Option<SymbolMappings>,
 }
 
-#[derive(Default, Clone, serde::Deserialize)]
+pub type SymbolMappings = BiBTreeMap<String, String>;
+
+#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
 pub struct ProjectObjectMetadata {
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub complete: Option<bool>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub reverse_fn_order: Option<bool>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub source_path: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub progress_categories: Option<Vec<String>>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub auto_generated: Option<bool>,
 }
 
-#[derive(Default, Clone, serde::Deserialize)]
+#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
 pub struct ProjectProgressCategory {
     #[serde(default)]
     pub id: String,
@@ -112,12 +135,12 @@ impl ProjectObject {
     }
 
     pub fn complete(&self) -> Option<bool> {
-        #[allow(deprecated)]
+        #[expect(deprecated)]
         self.metadata.as_ref().and_then(|m| m.complete).or(self.complete)
     }
 
     pub fn reverse_fn_order(&self) -> Option<bool> {
-        #[allow(deprecated)]
+        #[expect(deprecated)]
         self.metadata.as_ref().and_then(|m| m.reverse_fn_order).or(self.reverse_fn_order)
     }
 
@@ -132,16 +155,16 @@ impl ProjectObject {
 
 #[derive(Default, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
 pub struct ScratchConfig {
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub platform: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub compiler: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub c_flags: Option<String>,
-    #[serde(default)]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub ctx_path: Option<PathBuf>,
-    #[serde(default)]
-    pub build_ctx: bool,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub build_ctx: Option<bool>,
 }
 
 pub const CONFIG_FILENAMES: [&str; 3] = ["objdiff.json", "objdiff.yml", "objdiff.yaml"];
@@ -154,7 +177,7 @@ pub const DEFAULT_WATCH_PATTERNS: &[&str] = &[
 #[derive(Clone, Eq, PartialEq)]
 pub struct ProjectConfigInfo {
     pub path: PathBuf,
-    pub timestamp: FileTime,
+    pub timestamp: Option<FileTime>,
 }
 
 pub fn try_project_config(dir: &Path) -> Option<(Result<ProjectConfig>, ProjectConfigInfo)> {
@@ -180,12 +203,41 @@ pub fn try_project_config(dir: &Path) -> Option<(Result<ProjectConfig>, ProjectC
                     result = Err(e);
                 }
             }
-            return Some((result, ProjectConfigInfo { path: config_path, timestamp: ts }));
+            return Some((result, ProjectConfigInfo { path: config_path, timestamp: Some(ts) }));
         }
     }
     None
 }
 
+pub fn save_project_config(
+    config: &ProjectConfig,
+    info: &ProjectConfigInfo,
+) -> Result<ProjectConfigInfo> {
+    if let Some(last_ts) = info.timestamp {
+        // Check if the file has changed since we last read it
+        if let Ok(metadata) = fs::metadata(&info.path) {
+            let ts = FileTime::from_last_modification_time(&metadata);
+            if ts != last_ts {
+                return Err(anyhow!("Config file has changed since last read"));
+            }
+        }
+    }
+    let mut writer =
+        BufWriter::new(File::create(&info.path).context("Failed to create config file")?);
+    let ext = info.path.extension().and_then(|ext| ext.to_str()).unwrap_or("json");
+    match ext {
+        "json" => serde_json::to_writer_pretty(&mut writer, config).context("Failed to write JSON"),
+        "yml" | "yaml" => {
+            serde_yaml::to_writer(&mut writer, config).context("Failed to write YAML")
+        }
+        _ => Err(anyhow!("Unknown config file extension: {ext}")),
+    }?;
+    let file = writer.into_inner().context("Failed to flush file")?;
+    let metadata = file.metadata().context("Failed to get file metadata")?;
+    let ts = FileTime::from_last_modification_time(&metadata);
+    Ok(ProjectConfigInfo { path: info.path.clone(), timestamp: Some(ts) })
+}
+
 fn validate_min_version(config: &ProjectConfig) -> Result<()> {
     let Some(min_version) = &config.min_version else { return Ok(()) };
     let version = semver::Version::parse(env!("CARGO_PKG_VERSION"))
diff --git a/objdiff-core/src/diff/code.rs b/objdiff-core/src/diff/code.rs
index 8e99bd5..795c296 100644
--- a/objdiff-core/src/diff/code.rs
+++ b/objdiff-core/src/diff/code.rs
@@ -41,7 +41,7 @@ pub fn no_diff_code(out: &ProcessCodeResult, symbol_ref: SymbolRef) -> Result<Ob
         });
     }
     resolve_branches(&mut diff);
-    Ok(ObjSymbolDiff { symbol_ref, diff_symbol: None, instructions: diff, match_percent: None })
+    Ok(ObjSymbolDiff { symbol_ref, target_symbol: None, instructions: diff, match_percent: None })
 }
 
 pub fn diff_code(
@@ -67,7 +67,7 @@ pub fn diff_code(
         right.arg_diff = result.right_args_diff;
     }
 
-    let total = left_out.insts.len();
+    let total = left_out.insts.len().max(right_out.insts.len());
     let percent = if diff_state.diff_count >= total {
         0.0
     } else {
@@ -77,13 +77,13 @@ pub fn diff_code(
     Ok((
         ObjSymbolDiff {
             symbol_ref: left_symbol_ref,
-            diff_symbol: Some(right_symbol_ref),
+            target_symbol: Some(right_symbol_ref),
             instructions: left_diff,
             match_percent: Some(percent),
         },
         ObjSymbolDiff {
             symbol_ref: right_symbol_ref,
-            diff_symbol: Some(left_symbol_ref),
+            target_symbol: Some(left_symbol_ref),
             instructions: right_diff,
             match_percent: Some(percent),
         },
@@ -211,7 +211,7 @@ fn arg_eq(
     left_diff: &ObjInsDiff,
     right_diff: &ObjInsDiff,
 ) -> bool {
-    return match left {
+    match left {
         ObjInsArg::PlainText(l) => match right {
             ObjInsArg::PlainText(r) => l == r,
             _ => false,
@@ -236,7 +236,7 @@ fn arg_eq(
             left_diff.branch_to.as_ref().map(|b| b.ins_idx)
                 == right_diff.branch_to.as_ref().map(|b| b.ins_idx)
         }
-    };
+    }
 }
 
 #[derive(Default)]
diff --git a/objdiff-core/src/diff/data.rs b/objdiff-core/src/diff/data.rs
index 4498bff..8498439 100644
--- a/objdiff-core/src/diff/data.rs
+++ b/objdiff-core/src/diff/data.rs
@@ -20,13 +20,13 @@ pub fn diff_bss_symbol(
     Ok((
         ObjSymbolDiff {
             symbol_ref: left_symbol_ref,
-            diff_symbol: Some(right_symbol_ref),
+            target_symbol: Some(right_symbol_ref),
             instructions: vec![],
             match_percent: Some(percent),
         },
         ObjSymbolDiff {
             symbol_ref: right_symbol_ref,
-            diff_symbol: Some(left_symbol_ref),
+            target_symbol: Some(left_symbol_ref),
             instructions: vec![],
             match_percent: Some(percent),
         },
@@ -34,7 +34,7 @@ pub fn diff_bss_symbol(
 }
 
 pub fn no_diff_symbol(_obj: &ObjInfo, symbol_ref: SymbolRef) -> ObjSymbolDiff {
-    ObjSymbolDiff { symbol_ref, diff_symbol: None, instructions: vec![], match_percent: None }
+    ObjSymbolDiff { symbol_ref, target_symbol: None, instructions: vec![], match_percent: None }
 }
 
 /// Compare the data sections of two object files.
@@ -158,13 +158,13 @@ pub fn diff_data_symbol(
     Ok((
         ObjSymbolDiff {
             symbol_ref: left_symbol_ref,
-            diff_symbol: Some(right_symbol_ref),
+            target_symbol: Some(right_symbol_ref),
             instructions: vec![],
             match_percent: Some(match_percent),
         },
         ObjSymbolDiff {
             symbol_ref: right_symbol_ref,
-            diff_symbol: Some(left_symbol_ref),
+            target_symbol: Some(left_symbol_ref),
             instructions: vec![],
             match_percent: Some(match_percent),
         },
diff --git a/objdiff-core/src/diff/display.rs b/objdiff-core/src/diff/display.rs
index 40a1ad4..3435181 100644
--- a/objdiff-core/src/diff/display.rs
+++ b/objdiff-core/src/diff/display.rs
@@ -29,7 +29,7 @@ pub enum DiffText<'a> {
     Eol,
 }
 
-#[derive(Default, Clone, PartialEq, Eq)]
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
 pub enum HighlightKind {
     #[default]
     None,
diff --git a/objdiff-core/src/diff/mod.rs b/objdiff-core/src/diff/mod.rs
index caa35b5..f9e5783 100644
--- a/objdiff-core/src/diff/mod.rs
+++ b/objdiff-core/src/diff/mod.rs
@@ -3,6 +3,7 @@ use std::collections::HashSet;
 use anyhow::Result;
 
 use crate::{
+    config::SymbolMappings,
     diff::{
         code::{diff_code, no_diff_code, process_code_symbol},
         data::{
@@ -161,6 +162,8 @@ pub struct DiffObjConfig {
     #[serde(default = "default_true")]
     pub space_between_args: bool,
     pub combine_data_sections: bool,
+    #[serde(default)]
+    pub symbol_mappings: MappingConfig,
     // x86
     pub x86_formatter: X86Formatter,
     // MIPS
@@ -182,6 +185,7 @@ impl Default for DiffObjConfig {
             relax_reloc_diffs: false,
             space_between_args: true,
             combine_data_sections: false,
+            symbol_mappings: Default::default(),
             x86_formatter: Default::default(),
             mips_abi: Default::default(),
             mips_instr_category: Default::default(),
@@ -223,8 +227,10 @@ impl ObjSectionDiff {
 
 #[derive(Debug, Clone, Default)]
 pub struct ObjSymbolDiff {
+    /// The symbol ref this object
     pub symbol_ref: SymbolRef,
-    pub diff_symbol: Option<SymbolRef>,
+    /// The symbol ref in the _other_ object that this symbol was diffed against
+    pub target_symbol: Option<SymbolRef>,
     pub instructions: Vec<ObjInsDiff>,
     pub match_percent: Option<f32>,
 }
@@ -294,8 +300,13 @@ pub struct ObjInsBranchTo {
 
 #[derive(Default)]
 pub struct ObjDiff {
+    /// A list of all section diffs in the object.
     pub sections: Vec<ObjSectionDiff>,
+    /// Common BSS symbols don't live in a section, so they're stored separately.
     pub common: Vec<ObjSymbolDiff>,
+    /// If `selecting_left` or `selecting_right` is set, this is the list of symbols
+    /// that are being mapped to the other object.
+    pub mapping_symbols: Vec<ObjSymbolDiff>,
 }
 
 impl ObjDiff {
@@ -303,13 +314,14 @@ impl ObjDiff {
         let mut result = Self {
             sections: Vec::with_capacity(obj.sections.len()),
             common: Vec::with_capacity(obj.common.len()),
+            mapping_symbols: vec![],
         };
         for (section_idx, section) in obj.sections.iter().enumerate() {
             let mut symbols = Vec::with_capacity(section.symbols.len());
             for (symbol_idx, _) in section.symbols.iter().enumerate() {
                 symbols.push(ObjSymbolDiff {
                     symbol_ref: SymbolRef { section_idx, symbol_idx },
-                    diff_symbol: None,
+                    target_symbol: None,
                     instructions: vec![],
                     match_percent: None,
                 });
@@ -328,7 +340,7 @@ impl ObjDiff {
         for (symbol_idx, _) in obj.common.iter().enumerate() {
             result.common.push(ObjSymbolDiff {
                 symbol_ref: SymbolRef { section_idx: obj.sections.len(), symbol_idx },
-                diff_symbol: None,
+                target_symbol: None,
                 instructions: vec![],
                 match_percent: None,
             });
@@ -378,7 +390,7 @@ pub fn diff_objs(
     right: Option<&ObjInfo>,
     prev: Option<&ObjInfo>,
 ) -> Result<DiffObjsResult> {
-    let symbol_matches = matching_symbols(left, right, prev)?;
+    let symbol_matches = matching_symbols(left, right, prev, &config.symbol_mappings)?;
     let section_matches = matching_sections(left, right)?;
     let mut left = left.map(|p| (p, ObjDiff::new_from_obj(p)));
     let mut right = right.map(|p| (p, ObjDiff::new_from_obj(p)));
@@ -529,6 +541,17 @@ pub fn diff_objs(
         }
     }
 
+    if let (Some((right_obj, right_out)), Some((left_obj, left_out))) =
+        (right.as_mut(), left.as_mut())
+    {
+        if let Some(right_name) = &config.symbol_mappings.selecting_left {
+            generate_mapping_symbols(right_obj, right_name, left_obj, left_out, config)?;
+        }
+        if let Some(left_name) = &config.symbol_mappings.selecting_right {
+            generate_mapping_symbols(left_obj, left_name, right_obj, right_out, config)?;
+        }
+    }
+
     Ok(DiffObjsResult {
         left: left.map(|(_, o)| o),
         right: right.map(|(_, o)| o),
@@ -536,6 +559,63 @@ pub fn diff_objs(
     })
 }
 
+/// When we're selecting a symbol to use as a comparison, we'll create comparisons for all
+/// symbols in the other object that match the selected symbol's section and kind. This allows
+/// us to display match percentages for all symbols in the other object that could be selected.
+fn generate_mapping_symbols(
+    base_obj: &ObjInfo,
+    base_name: &str,
+    target_obj: &ObjInfo,
+    target_out: &mut ObjDiff,
+    config: &DiffObjConfig,
+) -> Result<()> {
+    let Some(base_symbol_ref) = symbol_ref_by_name(base_obj, base_name) else {
+        return Ok(());
+    };
+    let (base_section, base_symbol) = base_obj.section_symbol(base_symbol_ref);
+    let Some(base_section) = base_section else {
+        return Ok(());
+    };
+    let base_code = match base_section.kind {
+        ObjSectionKind::Code => Some(process_code_symbol(base_obj, base_symbol_ref, config)?),
+        _ => None,
+    };
+    for (target_section_index, target_section) in
+        target_obj.sections.iter().enumerate().filter(|(_, s)| s.kind == base_section.kind)
+    {
+        for (target_symbol_index, _target_symbol) in
+            target_section.symbols.iter().enumerate().filter(|(_, s)| s.kind == base_symbol.kind)
+        {
+            let target_symbol_ref =
+                SymbolRef { section_idx: target_section_index, symbol_idx: target_symbol_index };
+            match base_section.kind {
+                ObjSectionKind::Code => {
+                    let target_code = process_code_symbol(target_obj, target_symbol_ref, config)?;
+                    let (left_diff, _right_diff) = diff_code(
+                        &target_code,
+                        base_code.as_ref().unwrap(),
+                        target_symbol_ref,
+                        base_symbol_ref,
+                        config,
+                    )?;
+                    target_out.mapping_symbols.push(left_diff);
+                }
+                ObjSectionKind::Data => {
+                    let (left_diff, _right_diff) =
+                        diff_data_symbol(target_obj, base_obj, target_symbol_ref, base_symbol_ref)?;
+                    target_out.mapping_symbols.push(left_diff);
+                }
+                ObjSectionKind::Bss => {
+                    let (left_diff, _right_diff) =
+                        diff_bss_symbol(target_obj, base_obj, target_symbol_ref, base_symbol_ref)?;
+                    target_out.mapping_symbols.push(left_diff);
+                }
+            }
+        }
+    }
+    Ok(())
+}
+
 #[derive(Copy, Clone, Eq, PartialEq)]
 struct SymbolMatch {
     left: Option<SymbolRef>,
@@ -551,19 +631,115 @@ struct SectionMatch {
     section_kind: ObjSectionKind,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Deserialize, serde::Serialize)]
+pub struct MappingConfig {
+    /// Manual symbol mappings
+    pub mappings: SymbolMappings,
+    /// The right object symbol name that we're selecting a left symbol for
+    pub selecting_left: Option<String>,
+    /// The left object symbol name that we're selecting a right symbol for
+    pub selecting_right: Option<String>,
+}
+
+fn symbol_ref_by_name(obj: &ObjInfo, name: &str) -> Option<SymbolRef> {
+    for (section_idx, section) in obj.sections.iter().enumerate() {
+        for (symbol_idx, symbol) in section.symbols.iter().enumerate() {
+            if symbol.name == name {
+                return Some(SymbolRef { section_idx, symbol_idx });
+            }
+        }
+    }
+    None
+}
+
+fn apply_symbol_mappings(
+    left: &ObjInfo,
+    right: &ObjInfo,
+    mapping_config: &MappingConfig,
+    left_used: &mut HashSet<SymbolRef>,
+    right_used: &mut HashSet<SymbolRef>,
+    matches: &mut Vec<SymbolMatch>,
+) -> Result<()> {
+    // If we're selecting a symbol to use as a comparison, mark it as used
+    // This ensures that we don't match it to another symbol at any point
+    if let Some(left_name) = &mapping_config.selecting_left {
+        if let Some(left_symbol) = symbol_ref_by_name(left, left_name) {
+            left_used.insert(left_symbol);
+        }
+    }
+    if let Some(right_name) = &mapping_config.selecting_right {
+        if let Some(right_symbol) = symbol_ref_by_name(right, right_name) {
+            right_used.insert(right_symbol);
+        }
+    }
+
+    // Apply manual symbol mappings
+    for (left_name, right_name) in &mapping_config.mappings {
+        let Some(left_symbol) = symbol_ref_by_name(left, left_name) else {
+            continue;
+        };
+        if left_used.contains(&left_symbol) {
+            continue;
+        }
+        let Some(right_symbol) = symbol_ref_by_name(right, right_name) else {
+            continue;
+        };
+        if right_used.contains(&right_symbol) {
+            continue;
+        }
+        let left_section = &left.sections[left_symbol.section_idx];
+        let right_section = &right.sections[right_symbol.section_idx];
+        if left_section.kind != right_section.kind {
+            log::warn!(
+                "Symbol section kind mismatch: {} ({:?}) vs {} ({:?})",
+                left_name,
+                left_section.kind,
+                right_name,
+                right_section.kind
+            );
+            continue;
+        }
+        matches.push(SymbolMatch {
+            left: Some(left_symbol),
+            right: Some(right_symbol),
+            prev: None, // TODO
+            section_kind: left_section.kind,
+        });
+        left_used.insert(left_symbol);
+        right_used.insert(right_symbol);
+    }
+    Ok(())
+}
+
 /// Find matching symbols between each object.
 fn matching_symbols(
     left: Option<&ObjInfo>,
     right: Option<&ObjInfo>,
     prev: Option<&ObjInfo>,
+    mappings: &MappingConfig,
 ) -> Result<Vec<SymbolMatch>> {
     let mut matches = Vec::new();
+    let mut left_used = HashSet::new();
     let mut right_used = HashSet::new();
     if let Some(left) = left {
+        if let Some(right) = right {
+            apply_symbol_mappings(
+                left,
+                right,
+                mappings,
+                &mut left_used,
+                &mut right_used,
+                &mut matches,
+            )?;
+        }
         for (section_idx, section) in left.sections.iter().enumerate() {
             for (symbol_idx, symbol) in section.symbols.iter().enumerate() {
+                let symbol_ref = SymbolRef { section_idx, symbol_idx };
+                if left_used.contains(&symbol_ref) {
+                    continue;
+                }
                 let symbol_match = SymbolMatch {
-                    left: Some(SymbolRef { section_idx, symbol_idx }),
+                    left: Some(symbol_ref),
                     right: find_symbol(right, symbol, section, Some(&right_used)),
                     prev: find_symbol(prev, symbol, section, None),
                     section_kind: section.kind,
@@ -575,8 +751,12 @@ fn matching_symbols(
             }
         }
         for (symbol_idx, symbol) in left.common.iter().enumerate() {
+            let symbol_ref = SymbolRef { section_idx: left.sections.len(), symbol_idx };
+            if left_used.contains(&symbol_ref) {
+                continue;
+            }
             let symbol_match = SymbolMatch {
-                left: Some(SymbolRef { section_idx: left.sections.len(), symbol_idx }),
+                left: Some(symbol_ref),
                 right: find_common_symbol(right, symbol),
                 prev: find_common_symbol(prev, symbol),
                 section_kind: ObjSectionKind::Bss,
diff --git a/objdiff-core/src/obj/mod.rs b/objdiff-core/src/obj/mod.rs
index 6e96d85..68af116 100644
--- a/objdiff-core/src/obj/mod.rs
+++ b/objdiff-core/src/obj/mod.rs
@@ -112,6 +112,15 @@ pub struct ObjIns {
     pub orig: Option<String>,
 }
 
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
+pub enum ObjSymbolKind {
+    #[default]
+    Unknown,
+    Function,
+    Object,
+    Section,
+}
+
 #[derive(Debug, Clone)]
 pub struct ObjSymbol {
     pub name: String,
@@ -120,6 +129,7 @@ pub struct ObjSymbol {
     pub section_address: u64,
     pub size: u64,
     pub size_known: bool,
+    pub kind: ObjSymbolKind,
     pub flags: ObjSymbolFlagSet,
     pub addend: i64,
     /// Original virtual address (from .note.split section)
diff --git a/objdiff-core/src/obj/read.rs b/objdiff-core/src/obj/read.rs
index c7d4265..6154bf5 100644
--- a/objdiff-core/src/obj/read.rs
+++ b/objdiff-core/src/obj/read.rs
@@ -23,6 +23,7 @@ use crate::{
     obj::{
         split_meta::{SplitMeta, SPLITMETA_SECTION},
         ObjInfo, ObjReloc, ObjSection, ObjSectionKind, ObjSymbol, ObjSymbolFlagSet, ObjSymbolFlags,
+        ObjSymbolKind,
     },
     util::{read_u16, read_u32},
 };
@@ -94,6 +95,13 @@ fn to_obj_symbol(
         })
         .unwrap_or(&[]);
 
+    let kind = match symbol.kind() {
+        SymbolKind::Text => ObjSymbolKind::Function,
+        SymbolKind::Data => ObjSymbolKind::Object,
+        SymbolKind::Section => ObjSymbolKind::Section,
+        _ => ObjSymbolKind::Unknown,
+    };
+
     Ok(ObjSymbol {
         name: name.to_string(),
         demangled_name,
@@ -101,6 +109,7 @@ fn to_obj_symbol(
         section_address,
         size: symbol.size(),
         size_known: symbol.size() != 0,
+        kind,
         flags,
         addend,
         virtual_address,
@@ -173,12 +182,19 @@ fn symbols_by_section(
     result.sort_by(|a, b| a.address.cmp(&b.address).then(a.size.cmp(&b.size)));
     let mut iter = result.iter_mut().peekable();
     while let Some(symbol) = iter.next() {
-        if symbol.size == 0 {
+        if symbol.kind == ObjSymbolKind::Unknown && symbol.size == 0 {
             if let Some(next_symbol) = iter.peek() {
                 symbol.size = next_symbol.address - symbol.address;
             } else {
                 symbol.size = (section.address + section.size) - symbol.address;
             }
+            // Set symbol kind if we ended up with a non-zero size
+            if symbol.size > 0 {
+                symbol.kind = match section.kind {
+                    ObjSectionKind::Code => ObjSymbolKind::Function,
+                    ObjSectionKind::Data | ObjSectionKind::Bss => ObjSymbolKind::Object,
+                };
+            }
         }
     }
     if result.is_empty() {
@@ -196,6 +212,10 @@ fn symbols_by_section(
             section_address: 0,
             size: section.size,
             size_known: true,
+            kind: match section.kind {
+                ObjSectionKind::Code => ObjSymbolKind::Function,
+                ObjSectionKind::Data | ObjSectionKind::Bss => ObjSymbolKind::Object,
+            },
             flags: Default::default(),
             addend: 0,
             virtual_address: None,
@@ -281,6 +301,7 @@ fn find_section_symbol(
         section_address: 0,
         size: 0,
         size_known: false,
+        kind: ObjSymbolKind::Section,
         flags: Default::default(),
         addend: address as i64 - section.address() as i64,
         virtual_address: None,
@@ -568,6 +589,7 @@ fn update_combined_symbol(symbol: ObjSymbol, address_change: i64) -> Result<ObjS
         section_address: (symbol.section_address as i64 + address_change).try_into()?,
         size: symbol.size,
         size_known: symbol.size_known,
+        kind: symbol.kind,
         flags: symbol.flags,
         addend: symbol.addend,
         virtual_address: if let Some(virtual_address) = symbol.virtual_address {
diff --git a/objdiff-gui/src/app.rs b/objdiff-gui/src/app.rs
index a73056d..899fc51 100644
--- a/objdiff-gui/src/app.rs
+++ b/objdiff-gui/src/app.rs
@@ -15,7 +15,8 @@ use globset::{Glob, GlobSet};
 use notify::{RecursiveMode, Watcher};
 use objdiff_core::{
     config::{
-        build_globset, ProjectConfigInfo, ProjectObject, ScratchConfig, DEFAULT_WATCH_PATTERNS,
+        build_globset, save_project_config, ProjectConfig, ProjectConfigInfo, ProjectObject,
+        ScratchConfig, SymbolMappings, DEFAULT_WATCH_PATTERNS,
     },
     diff::DiffObjConfig,
 };
@@ -42,7 +43,7 @@ use crate::{
         graphics::{graphics_window, GraphicsConfig, GraphicsViewState},
         jobs::{jobs_menu_ui, jobs_window},
         rlwinm::{rlwinm_decode_window, RlwinmDecodeViewState},
-        symbol_diff::{symbol_diff_ui, DiffViewState, View},
+        symbol_diff::{symbol_diff_ui, DiffViewAction, DiffViewNavigation, DiffViewState, View},
     },
 };
 
@@ -89,7 +90,7 @@ impl Default for ViewState {
 }
 
 /// The configuration for a single object file.
-#[derive(Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
+#[derive(Default, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
 pub struct ObjectConfig {
     pub name: String,
     pub target_path: Option<PathBuf>,
@@ -98,6 +99,23 @@ pub struct ObjectConfig {
     pub complete: Option<bool>,
     pub scratch: Option<ScratchConfig>,
     pub source_path: Option<String>,
+    #[serde(default)]
+    pub symbol_mappings: SymbolMappings,
+}
+
+impl From<&ProjectObject> for ObjectConfig {
+    fn from(object: &ProjectObject) -> Self {
+        Self {
+            name: object.name().to_string(),
+            target_path: object.target_path.clone(),
+            base_path: object.base_path.clone(),
+            reverse_fn_order: object.reverse_fn_order(),
+            complete: object.complete(),
+            scratch: object.scratch.clone(),
+            source_path: object.source_path().cloned(),
+            symbol_mappings: object.symbol_mappings.clone().unwrap_or_default(),
+        }
+    }
 }
 
 #[inline]
@@ -117,8 +135,14 @@ pub struct AppState {
     pub obj_change: bool,
     pub queue_build: bool,
     pub queue_reload: bool,
+    pub current_project_config: Option<ProjectConfig>,
     pub project_config_info: Option<ProjectConfigInfo>,
     pub last_mod_check: Instant,
+    /// The right object symbol name that we're selecting a left symbol for
+    pub selecting_left: Option<String>,
+    /// The left object symbol name that we're selecting a right symbol for
+    pub selecting_right: Option<String>,
+    pub config_error: Option<String>,
 }
 
 impl Default for AppState {
@@ -132,8 +156,12 @@ impl Default for AppState {
             obj_change: false,
             queue_build: false,
             queue_reload: false,
+            current_project_config: None,
             project_config_info: None,
             last_mod_check: Instant::now(),
+            selecting_left: None,
+            selecting_right: None,
+            config_error: None,
         }
     }
 }
@@ -214,7 +242,10 @@ impl AppState {
         self.config_change = true;
         self.obj_change = true;
         self.queue_build = false;
+        self.current_project_config = None;
         self.project_config_info = None;
+        self.selecting_left = None;
+        self.selecting_right = None;
     }
 
     pub fn set_target_obj_dir(&mut self, path: PathBuf) {
@@ -222,6 +253,8 @@ impl AppState {
         self.config.selected_obj = None;
         self.obj_change = true;
         self.queue_build = false;
+        self.selecting_left = None;
+        self.selecting_right = None;
     }
 
     pub fn set_base_obj_dir(&mut self, path: PathBuf) {
@@ -229,12 +262,122 @@ impl AppState {
         self.config.selected_obj = None;
         self.obj_change = true;
         self.queue_build = false;
+        self.selecting_left = None;
+        self.selecting_right = None;
     }
 
-    pub fn set_selected_obj(&mut self, object: ObjectConfig) {
-        self.config.selected_obj = Some(object);
+    pub fn set_selected_obj(&mut self, config: ObjectConfig) {
+        if self.config.selected_obj.as_ref().is_some_and(|existing| existing == &config) {
+            // Don't reload the object if there were no changes
+            return;
+        }
+        self.config.selected_obj = Some(config);
+        self.obj_change = true;
+        self.queue_build = false;
+        self.selecting_left = None;
+        self.selecting_right = None;
+    }
+
+    pub fn clear_selected_obj(&mut self) {
+        self.config.selected_obj = None;
         self.obj_change = true;
         self.queue_build = false;
+        self.selecting_left = None;
+        self.selecting_right = None;
+    }
+
+    pub fn set_selecting_left(&mut self, right: &str) {
+        let Some(object) = self.config.selected_obj.as_mut() else {
+            return;
+        };
+        object.symbol_mappings.remove_by_right(right);
+        self.selecting_left = Some(right.to_string());
+        self.queue_reload = true;
+        self.save_config();
+    }
+
+    pub fn set_selecting_right(&mut self, left: &str) {
+        let Some(object) = self.config.selected_obj.as_mut() else {
+            return;
+        };
+        object.symbol_mappings.remove_by_left(left);
+        self.selecting_right = Some(left.to_string());
+        self.queue_reload = true;
+        self.save_config();
+    }
+
+    pub fn set_symbol_mapping(&mut self, left: String, right: String) {
+        let Some(object) = self.config.selected_obj.as_mut() else {
+            log::warn!("No selected object");
+            return;
+        };
+        self.selecting_left = None;
+        self.selecting_right = None;
+        if left == right {
+            object.symbol_mappings.remove_by_left(&left);
+            object.symbol_mappings.remove_by_right(&right);
+        } else {
+            object.symbol_mappings.insert(left.clone(), right.clone());
+        }
+        self.queue_reload = true;
+        self.save_config();
+    }
+
+    pub fn clear_selection(&mut self) {
+        self.selecting_left = None;
+        self.selecting_right = None;
+        self.queue_reload = true;
+    }
+
+    pub fn clear_mappings(&mut self) {
+        self.selecting_left = None;
+        self.selecting_right = None;
+        if let Some(object) = self.config.selected_obj.as_mut() {
+            object.symbol_mappings.clear();
+        }
+        self.queue_reload = true;
+        self.save_config();
+    }
+
+    pub fn is_selecting_symbol(&self) -> bool {
+        self.selecting_left.is_some() || self.selecting_right.is_some()
+    }
+
+    pub fn save_config(&mut self) {
+        let (Some(config), Some(info)) =
+            (self.current_project_config.as_mut(), self.project_config_info.as_mut())
+        else {
+            return;
+        };
+        // Update the project config with the current state
+        if let Some(object) = self.config.selected_obj.as_ref() {
+            if let Some(existing) = config.units.as_mut().and_then(|v| {
+                v.iter_mut().find(|u| u.name.as_ref().is_some_and(|n| n == &object.name))
+            }) {
+                existing.symbol_mappings = if object.symbol_mappings.is_empty() {
+                    None
+                } else {
+                    Some(object.symbol_mappings.clone())
+                };
+            }
+            if let Some(existing) =
+                self.objects.iter_mut().find(|u| u.name.as_ref().is_some_and(|n| n == &object.name))
+            {
+                existing.symbol_mappings = if object.symbol_mappings.is_empty() {
+                    None
+                } else {
+                    Some(object.symbol_mappings.clone())
+                };
+            }
+        }
+        // Save the updated project config
+        match save_project_config(config, info) {
+            Ok(new_info) => *info = new_info,
+            Err(e) => {
+                log::error!("Failed to save project config: {e}");
+                self.config_error = Some(format!("Failed to save project config: {e}"));
+            }
+        }
     }
 }
 
@@ -373,31 +516,41 @@ impl App {
         debug_assert!(jobs.results.is_empty());
     }
 
-    fn post_update(&mut self, ctx: &egui::Context) {
+    fn post_update(&mut self, ctx: &egui::Context, action: Option<DiffViewAction>) {
         self.appearance.post_update(ctx);
 
         let ViewState { jobs, diff_state, config_state, graphics_state, .. } = &mut self.view_state;
         config_state.post_update(ctx, jobs, &self.state);
-        diff_state.post_update(ctx, jobs, &self.state);
+        diff_state.post_update(action, ctx, jobs, &self.state);
 
         let Ok(mut state) = self.state.write() else {
             return;
         };
         let state = &mut *state;
 
-        if let Some(info) = &state.project_config_info {
-            if file_modified(&info.path, info.timestamp) {
-                state.config_change = true;
+        let mut mod_check = false;
+        if state.last_mod_check.elapsed().as_millis() >= 500 {
+            state.last_mod_check = Instant::now();
+            mod_check = true;
+        }
+
+        if mod_check {
+            if let Some(info) = &state.project_config_info {
+                if let Some(last_ts) = info.timestamp {
+                    if file_modified(&info.path, last_ts) {
+                        state.config_change = true;
+                    }
+                }
             }
         }
 
         if state.config_change {
             state.config_change = false;
             match load_project_config(state) {
-                Ok(()) => config_state.load_error = None,
+                Ok(()) => state.config_error = None,
                 Err(e) => {
                     log::error!("Failed to load project config: {e}");
-                    config_state.load_error = Some(format!("{e}"));
+                    state.config_error = Some(format!("{e}"));
                 }
             }
         }
@@ -432,8 +585,7 @@ impl App {
         }
 
         if let Some(result) = &diff_state.build {
-            if state.last_mod_check.elapsed().as_millis() >= 500 {
-                state.last_mod_check = Instant::now();
+            if mod_check {
                 if let Some((obj, _)) = &result.first_obj {
                     if let (Some(path), Some(timestamp)) = (&obj.path, obj.timestamp) {
                         if file_modified(path, timestamp) {
@@ -457,11 +609,11 @@ impl App {
             && state.config.selected_obj.is_some()
             && !jobs.is_running(Job::ObjDiff)
         {
-            jobs.push(start_build(ctx, ObjDiffConfig::from_config(&state.config)));
+            jobs.push(start_build(ctx, ObjDiffConfig::from_state(state)));
             state.queue_build = false;
             state.queue_reload = false;
         } else if state.queue_reload && !jobs.is_running(Job::ObjDiff) {
-            let mut diff_config = ObjDiffConfig::from_config(&state.config);
+            let mut diff_config = ObjDiffConfig::from_state(state);
             // Don't build, just reload the current files
             diff_config.build_base = false;
             diff_config.build_target = false;
@@ -636,6 +788,11 @@ impl eframe::App for App {
                     {
                         state.queue_reload = true;
                     }
+                    if ui.button("Clear custom symbol mappings").clicked() {
+                        state.clear_mappings();
+                        diff_state.post_build_nav = Some(DiffViewNavigation::symbol_diff());
+                        state.queue_reload = true;
+                    }
                 });
                 ui.separator();
                 if jobs_menu_ui(ui, jobs, appearance) {
@@ -652,17 +809,18 @@ impl eframe::App for App {
             });
         }
 
+        let mut action = None;
         egui::CentralPanel::default().show(ctx, |ui| {
             let build_success = matches!(&diff_state.build, Some(b) if b.first_status.success && b.second_status.success);
-            if diff_state.current_view == View::FunctionDiff && build_success {
-                function_diff_ui(ui, diff_state, appearance);
+            action = if diff_state.current_view == View::FunctionDiff && build_success {
+                function_diff_ui(ui, diff_state, appearance)
             } else if diff_state.current_view == View::DataDiff && build_success {
-                data_diff_ui(ui, diff_state, appearance);
+                data_diff_ui(ui, diff_state, appearance)
             } else if diff_state.current_view == View::ExtabDiff && build_success {
-                extab_diff_ui(ui, diff_state, appearance);
+                extab_diff_ui(ui, diff_state, appearance)
             } else {
-                symbol_diff_ui(ui, diff_state, appearance);
-            }
+                symbol_diff_ui(ui, diff_state, appearance)
+            };
         });
 
         project_window(ctx, state, show_project_config, config_state, appearance);
@@ -674,10 +832,10 @@ impl eframe::App for App {
         graphics_window(ctx, show_graphics, frame_history, graphics_state, appearance);
         jobs_window(ctx, show_jobs, jobs, appearance);
 
-        self.post_update(ctx);
+        self.post_update(ctx, action);
     }
 
-    /// Called by the frame work to save state before shutdown.
+    /// Called by the framework to save state before shutdown.
     fn save(&mut self, storage: &mut dyn eframe::Storage) {
         if let Ok(state) = self.state.read() {
             eframe::set_value(storage, CONFIG_KEY, &state.config);
diff --git a/objdiff-gui/src/app_config.rs b/objdiff-gui/src/app_config.rs
index f686ab6..ea456ce 100644
--- a/objdiff-gui/src/app_config.rs
+++ b/objdiff-gui/src/app_config.rs
@@ -2,6 +2,10 @@ use std::path::PathBuf;
 
 use eframe::Storage;
 use globset::Glob;
+use objdiff_core::{
+    config::ScratchConfig,
+    diff::{ArmArchVersion, ArmR9Usage, DiffObjConfig, MipsAbi, MipsInstrCategory, X86Formatter},
+};
 
 use crate::app::{AppConfig, ObjectConfig, CONFIG_KEY};
 
@@ -11,7 +15,7 @@ pub struct AppConfigVersion {
 }
 
 impl Default for AppConfigVersion {
-    fn default() -> Self { Self { version: 1 } }
+    fn default() -> Self { Self { version: 2 } }
 }
 
 /// Deserialize the AppConfig from storage, handling upgrades from older versions.
@@ -19,7 +23,8 @@ pub fn deserialize_config(storage: &dyn Storage) -> Option<AppConfig> {
     let str = storage.get_string(CONFIG_KEY)?;
     match ron::from_str::<AppConfigVersion>(&str) {
         Ok(version) => match version.version {
-            1 => from_str::<AppConfig>(&str),
+            2 => from_str::<AppConfig>(&str),
+            1 => from_str::<AppConfigV1>(&str).map(|c| c.into_config()),
             _ => {
                 log::warn!("Unknown config version: {}", version.version);
                 None
@@ -44,6 +49,180 @@ where T: serde::de::DeserializeOwned {
     }
 }
 
+#[derive(serde::Deserialize, serde::Serialize)]
+pub struct ScratchConfigV1 {
+    #[serde(default)]
+    pub platform: Option<String>,
+    #[serde(default)]
+    pub compiler: Option<String>,
+    #[serde(default)]
+    pub c_flags: Option<String>,
+    #[serde(default)]
+    pub ctx_path: Option<PathBuf>,
+    #[serde(default)]
+    pub build_ctx: bool,
+}
+
+impl ScratchConfigV1 {
+    fn into_config(self) -> ScratchConfig {
+        ScratchConfig {
+            platform: self.platform,
+            compiler: self.compiler,
+            c_flags: self.c_flags,
+            ctx_path: self.ctx_path,
+            build_ctx: self.build_ctx.then_some(true),
+        }
+    }
+}
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub struct ObjectConfigV1 {
+    pub name: String,
+    pub target_path: Option<PathBuf>,
+    pub base_path: Option<PathBuf>,
+    pub reverse_fn_order: Option<bool>,
+    pub complete: Option<bool>,
+    pub scratch: Option<ScratchConfigV1>,
+    pub source_path: Option<String>,
+}
+
+impl ObjectConfigV1 {
+    fn into_config(self) -> ObjectConfig {
+        ObjectConfig {
+            name: self.name,
+            target_path: self.target_path,
+            base_path: self.base_path,
+            reverse_fn_order: self.reverse_fn_order,
+            complete: self.complete,
+            scratch: self.scratch.map(|scratch| scratch.into_config()),
+            source_path: self.source_path,
+            ..Default::default()
+        }
+    }
+}
+
+#[derive(serde::Deserialize, serde::Serialize)]
+#[serde(default)]
+pub struct DiffObjConfigV1 {
+    pub relax_reloc_diffs: bool,
+    #[serde(default = "bool_true")]
+    pub space_between_args: bool,
+    pub combine_data_sections: bool,
+    // x86
+    pub x86_formatter: X86Formatter,
+    // MIPS
+    pub mips_abi: MipsAbi,
+    pub mips_instr_category: MipsInstrCategory,
+    // ARM
+    pub arm_arch_version: ArmArchVersion,
+    pub arm_unified_syntax: bool,
+    pub arm_av_registers: bool,
+    pub arm_r9_usage: ArmR9Usage,
+    pub arm_sl_usage: bool,
+    pub arm_fp_usage: bool,
+    pub arm_ip_usage: bool,
+}
+
+impl Default for DiffObjConfigV1 {
+    fn default() -> Self {
+        Self {
+            relax_reloc_diffs: false,
+            space_between_args: true,
+            combine_data_sections: false,
+            x86_formatter: Default::default(),
+            mips_abi: Default::default(),
+            mips_instr_category: Default::default(),
+            arm_arch_version: Default::default(),
+            arm_unified_syntax: true,
+            arm_av_registers: false,
+            arm_r9_usage: Default::default(),
+            arm_sl_usage: false,
+            arm_fp_usage: false,
+            arm_ip_usage: false,
+        }
+    }
+}
+
+impl DiffObjConfigV1 {
+    fn into_config(self) -> DiffObjConfig {
+        DiffObjConfig {
+            relax_reloc_diffs: self.relax_reloc_diffs,
+            space_between_args: self.space_between_args,
+            combine_data_sections: self.combine_data_sections,
+            x86_formatter: self.x86_formatter,
+            mips_abi: self.mips_abi,
+            mips_instr_category: self.mips_instr_category,
+            arm_arch_version: self.arm_arch_version,
+            arm_unified_syntax: self.arm_unified_syntax,
+            arm_av_registers: self.arm_av_registers,
+            arm_r9_usage: self.arm_r9_usage,
+            arm_sl_usage: self.arm_sl_usage,
+            arm_fp_usage: self.arm_fp_usage,
+            arm_ip_usage: self.arm_ip_usage,
+            ..Default::default()
+        }
+    }
+}
+
+#[inline]
+fn bool_true() -> bool { true }
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub struct AppConfigV1 {
+    pub version: u32,
+    #[serde(default)]
+    pub custom_make: Option<String>,
+    #[serde(default)]
+    pub custom_args: Option<Vec<String>>,
+    #[serde(default)]
+    pub selected_wsl_distro: Option<String>,
+    #[serde(default)]
+    pub project_dir: Option<PathBuf>,
+    #[serde(default)]
+    pub target_obj_dir: Option<PathBuf>,
+    #[serde(default)]
+    pub base_obj_dir: Option<PathBuf>,
+    #[serde(default)]
+    pub selected_obj: Option<ObjectConfigV1>,
+    #[serde(default = "bool_true")]
+    pub build_base: bool,
+    #[serde(default)]
+    pub build_target: bool,
+    #[serde(default = "bool_true")]
+    pub rebuild_on_changes: bool,
+    #[serde(default)]
+    pub auto_update_check: bool,
+    #[serde(default)]
+    pub watch_patterns: Vec<Glob>,
+    #[serde(default)]
+    pub recent_projects: Vec<PathBuf>,
+    #[serde(default)]
+    pub diff_obj_config: DiffObjConfigV1,
+}
+
+impl AppConfigV1 {
+    fn into_config(self) -> AppConfig {
+        log::info!("Upgrading configuration from v1");
+        AppConfig {
+            custom_make: self.custom_make,
+            custom_args: self.custom_args,
+            selected_wsl_distro: self.selected_wsl_distro,
+            project_dir: self.project_dir,
+            target_obj_dir: self.target_obj_dir,
+            base_obj_dir: self.base_obj_dir,
+            selected_obj: self.selected_obj.map(|obj| obj.into_config()),
+            build_base: self.build_base,
+            build_target: self.build_target,
+            rebuild_on_changes: self.rebuild_on_changes,
+            auto_update_check: self.auto_update_check,
+            watch_patterns: self.watch_patterns,
+            recent_projects: self.recent_projects,
+            diff_obj_config: self.diff_obj_config.into_config(),
+            ..Default::default()
+        }
+    }
+}
+
 #[derive(serde::Deserialize, serde::Serialize)]
 pub struct ObjectConfigV0 {
     pub name: String,
@@ -59,9 +238,7 @@ impl ObjectConfigV0 {
             target_path: Some(self.target_path),
             base_path: Some(self.base_path),
             reverse_fn_order: self.reverse_fn_order,
-            complete: None,
-            scratch: None,
-            source_path: None,
+            ..Default::default()
         }
     }
 }
diff --git a/objdiff-gui/src/config.rs b/objdiff-gui/src/config.rs
index 6721cf7..a99913e 100644
--- a/objdiff-gui/src/config.rs
+++ b/objdiff-gui/src/config.rs
@@ -4,11 +4,11 @@ use anyhow::Result;
 use globset::Glob;
 use objdiff_core::config::{try_project_config, ProjectObject, DEFAULT_WATCH_PATTERNS};
 
-use crate::app::AppState;
+use crate::app::{AppState, ObjectConfig};
 
 #[derive(Clone)]
 pub enum ProjectObjectNode {
-    File(String, Box<ProjectObject>),
+    Unit(String, usize),
     Dir(String, Vec<ProjectObjectNode>),
 }
 
@@ -33,17 +33,18 @@ fn find_dir<'a>(
 }
 
 fn build_nodes(
-    objects: &[ProjectObject],
+    units: &mut [ProjectObject],
     project_dir: &Path,
     target_obj_dir: Option<&Path>,
     base_obj_dir: Option<&Path>,
 ) -> Vec<ProjectObjectNode> {
     let mut nodes = vec![];
-    for object in objects {
+    for (idx, unit) in units.iter_mut().enumerate() {
+        unit.resolve_paths(project_dir, target_obj_dir, base_obj_dir);
         let mut out_nodes = &mut nodes;
-        let path = if let Some(name) = &object.name {
+        let path = if let Some(name) = &unit.name {
             Path::new(name)
-        } else if let Some(path) = &object.path {
+        } else if let Some(path) = &unit.path {
             path
         } else {
             continue;
@@ -56,10 +57,8 @@ fn build_nodes(
                 }
             }
         }
-        let mut object = Box::new(object.clone());
-        object.resolve_paths(project_dir, target_obj_dir, base_obj_dir);
         let filename = path.file_name().unwrap().to_str().unwrap().to_string();
-        out_nodes.push(ProjectObjectNode::File(filename, object));
+        out_nodes.push(ProjectObjectNode::Unit(filename, idx));
     }
     nodes
 }
@@ -70,24 +69,36 @@ pub fn load_project_config(state: &mut AppState) -> Result<()> {
     };
     if let Some((result, info)) = try_project_config(project_dir) {
         let project_config = result?;
-        state.config.custom_make = project_config.custom_make;
-        state.config.custom_args = project_config.custom_args;
-        state.config.target_obj_dir = project_config.target_dir.map(|p| project_dir.join(p));
-        state.config.base_obj_dir = project_config.base_dir.map(|p| project_dir.join(p));
-        state.config.build_base = project_config.build_base;
-        state.config.build_target = project_config.build_target;
-        state.config.watch_patterns = project_config.watch_patterns.unwrap_or_else(|| {
+        state.config.custom_make = project_config.custom_make.clone();
+        state.config.custom_args = project_config.custom_args.clone();
+        state.config.target_obj_dir =
+            project_config.target_dir.as_deref().map(|p| project_dir.join(p));
+        state.config.base_obj_dir = project_config.base_dir.as_deref().map(|p| project_dir.join(p));
+        state.config.build_base = project_config.build_base.unwrap_or(true);
+        state.config.build_target = project_config.build_target.unwrap_or(false);
+        state.config.watch_patterns = project_config.watch_patterns.clone().unwrap_or_else(|| {
             DEFAULT_WATCH_PATTERNS.iter().map(|s| Glob::new(s).unwrap()).collect()
         });
         state.watcher_change = true;
-        state.objects = project_config.objects;
+        state.objects = project_config.units.clone().unwrap_or_default();
         state.object_nodes = build_nodes(
-            &state.objects,
+            &mut state.objects,
             project_dir,
             state.config.target_obj_dir.as_deref(),
             state.config.base_obj_dir.as_deref(),
         );
+        state.current_project_config = Some(project_config);
         state.project_config_info = Some(info);
+
+        // Reload selected object
+        if let Some(selected_obj) = &state.config.selected_obj {
+            if let Some(obj) = state.objects.iter().find(|o| o.name() == selected_obj.name) {
+                let config = ObjectConfig::from(obj);
+                state.set_selected_obj(config);
+            } else {
+                state.clear_selected_obj();
+            }
+        }
     }
     Ok(())
 }
diff --git a/objdiff-gui/src/jobs/create_scratch.rs b/objdiff-gui/src/jobs/create_scratch.rs
index bd205c5..3d8ecd4 100644
--- a/objdiff-gui/src/jobs/create_scratch.rs
+++ b/objdiff-gui/src/jobs/create_scratch.rs
@@ -39,7 +39,7 @@ impl CreateScratchConfig {
         Ok(Self {
             build_config: BuildConfig::from_config(config),
             context_path: scratch_config.ctx_path.clone(),
-            build_context: scratch_config.build_ctx,
+            build_context: scratch_config.build_ctx.unwrap_or(false),
             compiler: scratch_config.compiler.clone().unwrap_or_default(),
             platform: scratch_config.platform.clone().unwrap_or_default(),
             compiler_flags: scratch_config.c_flags.clone().unwrap_or_default(),
diff --git a/objdiff-gui/src/jobs/mod.rs b/objdiff-gui/src/jobs/mod.rs
index 85ff3f5..1cf728f 100644
--- a/objdiff-gui/src/jobs/mod.rs
+++ b/objdiff-gui/src/jobs/mod.rs
@@ -53,7 +53,7 @@ impl JobQueue {
     }
 
     /// Returns whether any job is running.
-    #[allow(dead_code)]
+    #[expect(dead_code)]
     pub fn any_running(&self) -> bool {
         self.jobs.iter().any(|job| {
             if let Some(handle) = &job.handle {
diff --git a/objdiff-gui/src/jobs/objdiff.rs b/objdiff-gui/src/jobs/objdiff.rs
index 60bcd6d..0c69597 100644
--- a/objdiff-gui/src/jobs/objdiff.rs
+++ b/objdiff-gui/src/jobs/objdiff.rs
@@ -6,13 +6,13 @@ use std::{
 
 use anyhow::{anyhow, Error, Result};
 use objdiff_core::{
-    diff::{diff_objs, DiffObjConfig, ObjDiff},
+    diff::{diff_objs, DiffObjConfig, MappingConfig, ObjDiff},
     obj::{read, ObjInfo},
 };
 use time::OffsetDateTime;
 
 use crate::{
-    app::{AppConfig, ObjectConfig},
+    app::{AppConfig, AppState, ObjectConfig},
     jobs::{start_job, update_status, Job, JobContext, JobResult, JobState},
 };
 
@@ -60,16 +60,20 @@ pub struct ObjDiffConfig {
     pub build_target: bool,
     pub selected_obj: Option<ObjectConfig>,
     pub diff_obj_config: DiffObjConfig,
+    pub selecting_left: Option<String>,
+    pub selecting_right: Option<String>,
 }
 
 impl ObjDiffConfig {
-    pub(crate) fn from_config(config: &AppConfig) -> Self {
+    pub(crate) fn from_state(state: &AppState) -> Self {
         Self {
-            build_config: BuildConfig::from_config(config),
-            build_base: config.build_base,
-            build_target: config.build_target,
-            selected_obj: config.selected_obj.clone(),
-            diff_obj_config: config.diff_obj_config.clone(),
+            build_config: BuildConfig::from_config(&state.config),
+            build_base: state.config.build_base,
+            build_target: state.config.build_target,
+            selected_obj: state.config.selected_obj.clone(),
+            diff_obj_config: state.config.diff_obj_config.clone(),
+            selecting_left: state.selecting_left.clone(),
+            selecting_right: state.selecting_right.clone(),
         }
     }
 }
@@ -158,9 +162,16 @@ pub(crate) fn run_make(config: &BuildConfig, arg: &Path) -> BuildStatus {
 fn run_build(
     context: &JobContext,
     cancel: Receiver<()>,
-    config: ObjDiffConfig,
+    mut config: ObjDiffConfig,
 ) -> Result<Box<ObjDiffResult>> {
-    let obj_config = config.selected_obj.as_ref().ok_or_else(|| Error::msg("Missing obj path"))?;
+    let obj_config = config.selected_obj.ok_or_else(|| Error::msg("Missing obj path"))?;
+    // Use the per-object symbol mappings, we don't set mappings globally
+    config.diff_obj_config.symbol_mappings = MappingConfig {
+        mappings: obj_config.symbol_mappings,
+        selecting_left: config.selecting_left,
+        selecting_right: config.selecting_right,
+    };
+
     let project_dir = config
         .build_config
         .project_dir
diff --git a/objdiff-gui/src/views/column_layout.rs b/objdiff-gui/src/views/column_layout.rs
new file mode 100644
index 0000000..9148311
--- /dev/null
+++ b/objdiff-gui/src/views/column_layout.rs
@@ -0,0 +1,82 @@
+use egui::{Align, Layout, Sense, Vec2};
+use egui_extras::{Column, Size, StripBuilder, TableBuilder, TableRow};
+
+pub fn render_header(
+    ui: &mut egui::Ui,
+    available_width: f32,
+    num_columns: usize,
+    mut add_contents: impl FnMut(&mut egui::Ui, usize),
+) {
+    let column_width = available_width / num_columns as f32;
+    ui.allocate_ui_with_layout(
+        Vec2 { x: available_width, y: 100.0 },
+        Layout::left_to_right(Align::Min),
+        |ui| {
+            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
+            for i in 0..num_columns {
+                ui.allocate_ui_with_layout(
+                    Vec2 { x: column_width, y: 100.0 },
+                    Layout::top_down(Align::Min),
+                    |ui| {
+                        ui.set_width(column_width);
+                        add_contents(ui, i);
+                    },
+                );
+            }
+        },
+    );
+    ui.separator();
+}
+
+pub fn render_table(
+    ui: &mut egui::Ui,
+    available_width: f32,
+    num_columns: usize,
+    row_height: f32,
+    total_rows: usize,
+    mut add_contents: impl FnMut(&mut TableRow, usize),
+) {
+    ui.style_mut().interaction.selectable_labels = false;
+    let column_width = available_width / num_columns as f32;
+    let available_height = ui.available_height();
+    let table = TableBuilder::new(ui)
+        .striped(false)
+        .cell_layout(Layout::left_to_right(Align::Min))
+        .columns(Column::exact(column_width).clip(true), num_columns)
+        .resizable(false)
+        .auto_shrink([false, false])
+        .min_scrolled_height(available_height)
+        .sense(Sense::click());
+    table.body(|body| {
+        body.rows(row_height, total_rows, |mut row| {
+            row.set_hovered(false); // Disable hover effect
+            for i in 0..num_columns {
+                add_contents(&mut row, i);
+            }
+        });
+    });
+}
+
+pub fn render_strips(
+    ui: &mut egui::Ui,
+    available_width: f32,
+    num_columns: usize,
+    mut add_contents: impl FnMut(&mut egui::Ui, usize),
+) {
+    let column_width = available_width / num_columns as f32;
+    StripBuilder::new(ui).size(Size::remainder()).clip(true).vertical(|mut strip| {
+        strip.strip(|builder| {
+            builder.sizes(Size::exact(column_width), num_columns).clip(true).horizontal(
+                |mut strip| {
+                    for i in 0..num_columns {
+                        strip.cell(|ui| {
+                            ui.push_id(i, |ui| {
+                                add_contents(ui, i);
+                            });
+                        });
+                    }
+                },
+            );
+        });
+    });
+}
diff --git a/objdiff-gui/src/views/config.rs b/objdiff-gui/src/views/config.rs
index a54b951..6d7fd9e 100644
--- a/objdiff-gui/src/views/config.rs
+++ b/objdiff-gui/src/views/config.rs
@@ -43,7 +43,6 @@ pub struct ConfigViewState {
     pub build_running: bool,
     pub queue_build: bool,
     pub watch_pattern_text: String,
-    pub load_error: Option<String>,
     pub object_search: String,
     pub filter_diffable: bool,
     pub filter_incomplete: bool,
@@ -93,10 +92,7 @@ impl ConfigViewState {
                             name: obj_path.display().to_string(),
                             target_path: Some(target_path),
                             base_path: Some(path),
-                            reverse_fn_order: None,
-                            complete: None,
-                            scratch: None,
-                            source_path: None,
+                            ..Default::default()
                         });
                     } else if let Ok(obj_path) = path.strip_prefix(target_dir) {
                         let base_path = base_dir.join(obj_path);
@@ -104,10 +100,7 @@ impl ConfigViewState {
                             name: obj_path.display().to_string(),
                             target_path: Some(path),
                             base_path: Some(base_path),
-                            reverse_fn_order: None,
-                            complete: None,
-                            scratch: None,
-                            source_path: None,
+                            ..Default::default()
                         });
                     }
                 }
@@ -230,7 +223,10 @@ pub fn config_ui(
         }
     });
 
-    let mut new_selected_obj = selected_obj.clone();
+    let selected_index = selected_obj.as_ref().and_then(|selected_obj| {
+        objects.iter().position(|obj| obj.name.as_ref() == Some(&selected_obj.name))
+    });
+    let mut new_selected_index = selected_index;
     if objects.is_empty() {
         if let (Some(_base_dir), Some(target_dir)) = (base_obj_dir, target_obj_dir) {
             if ui.button("Select object").clicked() {
@@ -316,6 +312,7 @@ pub fn config_ui(
             ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
             for node in object_nodes.iter().filter_map(|node| {
                 filter_node(
+                    objects,
                     node,
                     &search,
                     config_state.filter_diffable,
@@ -325,8 +322,9 @@ pub fn config_ui(
             }) {
                 display_node(
                     ui,
-                    &mut new_selected_obj,
+                    &mut new_selected_index,
                     project_dir.as_deref(),
+                    objects,
                     &node,
                     appearance,
                     node_open,
@@ -334,10 +332,11 @@ pub fn config_ui(
             }
         });
     }
-    if new_selected_obj != *selected_obj {
-        if let Some(obj) = new_selected_obj {
+    if new_selected_index != selected_index {
+        if let Some(idx) = new_selected_index {
             // Will set obj_changed, which will trigger a rebuild
-            state_guard.set_selected_obj(obj);
+            let config = ObjectConfig::from(&objects[idx]);
+            state_guard.set_selected_obj(config);
         }
     }
     if state_guard.config.selected_obj.is_some()
@@ -347,16 +346,17 @@ pub fn config_ui(
     }
 }
 
-fn display_object(
+fn display_unit(
     ui: &mut egui::Ui,
-    selected_obj: &mut Option<ObjectConfig>,
+    selected_obj: &mut Option<usize>,
     project_dir: Option<&Path>,
     name: &str,
-    object: &ProjectObject,
+    units: &[ProjectObject],
+    index: usize,
     appearance: &Appearance,
 ) {
-    let object_name = object.name();
-    let selected = matches!(selected_obj, Some(obj) if obj.name == object_name);
+    let object = &units[index];
+    let selected = *selected_obj == Some(index);
     let color = if selected {
         appearance.emphasized_text_color
     } else if let Some(complete) = object.complete() {
@@ -381,18 +381,8 @@ fn display_object(
     if get_source_path(project_dir, object).is_some() {
         response.context_menu(|ui| object_context_ui(ui, object, project_dir));
     }
-    // Always recreate ObjectConfig if selected, in case the project config changed.
-    // ObjectConfig is compared using equality, so this won't unnecessarily trigger a rebuild.
-    if selected || response.clicked() {
-        *selected_obj = Some(ObjectConfig {
-            name: object_name.to_string(),
-            target_path: object.target_path.clone(),
-            base_path: object.base_path.clone(),
-            reverse_fn_order: object.reverse_fn_order(),
-            complete: object.complete(),
-            scratch: object.scratch.clone(),
-            source_path: object.source_path().cloned(),
-        });
+    if response.clicked() {
+        *selected_obj = Some(index);
     }
 }
 
@@ -427,18 +417,19 @@ enum NodeOpen {
 
 fn display_node(
     ui: &mut egui::Ui,
-    selected_obj: &mut Option<ObjectConfig>,
+    selected_obj: &mut Option<usize>,
     project_dir: Option<&Path>,
+    units: &[ProjectObject],
     node: &ProjectObjectNode,
     appearance: &Appearance,
     node_open: NodeOpen,
 ) {
     match node {
-        ProjectObjectNode::File(name, object) => {
-            display_object(ui, selected_obj, project_dir, name, object, appearance);
+        ProjectObjectNode::Unit(name, idx) => {
+            display_unit(ui, selected_obj, project_dir, name, units, *idx, appearance);
         }
         ProjectObjectNode::Dir(name, children) => {
-            let contains_obj = selected_obj.as_ref().map(|path| contains_node(node, path));
+            let contains_obj = selected_obj.map(|idx| contains_node(node, idx));
             let open = match node_open {
                 NodeOpen::Default => None,
                 NodeOpen::Open => Some(true),
@@ -461,16 +452,16 @@ fn display_node(
             .open(open)
             .show(ui, |ui| {
                 for node in children {
-                    display_node(ui, selected_obj, project_dir, node, appearance, node_open);
+                    display_node(ui, selected_obj, project_dir, units, node, appearance, node_open);
                 }
             });
         }
     }
 }
 
-fn contains_node(node: &ProjectObjectNode, selected_obj: &ObjectConfig) -> bool {
+fn contains_node(node: &ProjectObjectNode, selected_obj: usize) -> bool {
     match node {
-        ProjectObjectNode::File(_, object) => object.name() == selected_obj.name,
+        ProjectObjectNode::Unit(_, idx) => *idx == selected_obj,
         ProjectObjectNode::Dir(_, children) => {
             children.iter().any(|node| contains_node(node, selected_obj))
         }
@@ -478,6 +469,7 @@ fn contains_node(node: &ProjectObjectNode, selected_obj: &ObjectConfig) -> bool
 }
 
 fn filter_node(
+    units: &[ProjectObject],
     node: &ProjectObjectNode,
     search: &str,
     filter_diffable: bool,
@@ -485,12 +477,12 @@ fn filter_node(
     show_hidden: bool,
 ) -> Option<ProjectObjectNode> {
     match node {
-        ProjectObjectNode::File(name, object) => {
+        ProjectObjectNode::Unit(name, idx) => {
+            let unit = &units[*idx];
             if (search.is_empty() || name.to_ascii_lowercase().contains(search))
-                && (!filter_diffable
-                    || (object.base_path.is_some() && object.target_path.is_some()))
-                && (!filter_incomplete || matches!(object.complete(), None | Some(false)))
-                && (show_hidden || !object.hidden())
+                && (!filter_diffable || (unit.base_path.is_some() && unit.target_path.is_some()))
+                && (!filter_incomplete || matches!(unit.complete(), None | Some(false)))
+                && (show_hidden || !unit.hidden())
             {
                 Some(node.clone())
             } else {
@@ -501,7 +493,14 @@ fn filter_node(
             let new_children = children
                 .iter()
                 .filter_map(|child| {
-                    filter_node(child, search, filter_diffable, filter_incomplete, show_hidden)
+                    filter_node(
+                        units,
+                        child,
+                        search,
+                        filter_diffable,
+                        filter_incomplete,
+                        show_hidden,
+                    )
                 })
                 .collect::<Vec<_>>();
             if !new_children.is_empty() {
@@ -570,14 +569,14 @@ pub fn project_window(
         split_obj_config_ui(ui, &mut state_guard, config_state, appearance);
     });
 
-    if let Some(error) = &config_state.load_error {
+    if let Some(error) = &state_guard.config_error {
         let mut open = true;
         egui::Window::new("Error").open(&mut open).show(ctx, |ui| {
             ui.label("Failed to load project config:");
             ui.colored_label(appearance.delete_color, error);
         });
         if !open {
-            config_state.load_error = None;
+            state_guard.config_error = None;
         }
     }
 }
diff --git a/objdiff-gui/src/views/data_diff.rs b/objdiff-gui/src/views/data_diff.rs
index 1798596..224a169 100644
--- a/objdiff-gui/src/views/data_diff.rs
+++ b/objdiff-gui/src/views/data_diff.rs
@@ -1,7 +1,6 @@
 use std::{cmp::min, default::Default, mem::take};
 
-use egui::{text::LayoutJob, Align, Label, Layout, Sense, Vec2, Widget};
-use egui_extras::{Column, TableBuilder};
+use egui::{text::LayoutJob, Id, Label, RichText, Sense, Widget};
 use objdiff_core::{
     diff::{ObjDataDiff, ObjDataDiffKind, ObjDiff},
     obj::ObjInfo,
@@ -10,14 +9,15 @@ use time::format_description;
 
 use crate::views::{
     appearance::Appearance,
-    symbol_diff::{DiffViewState, SymbolRefByName, View},
+    column_layout::{render_header, render_table},
+    symbol_diff::{DiffViewAction, DiffViewNavigation, DiffViewState},
     write_text,
 };
 
 const BYTES_PER_ROW: usize = 16;
 
-fn find_section(obj: &ObjInfo, selected_symbol: &SymbolRefByName) -> Option<usize> {
-    obj.sections.iter().position(|section| section.name == selected_symbol.section_name)
+fn find_section(obj: &ObjInfo, section_name: &str) -> Option<usize> {
+    obj.sections.iter().position(|section| section.name == section_name)
 }
 
 fn data_row_ui(ui: &mut egui::Ui, address: usize, diffs: &[ObjDataDiff], appearance: &Appearance) {
@@ -131,20 +131,37 @@ fn split_diffs(diffs: &[ObjDataDiff]) -> Vec<Vec<ObjDataDiff>> {
     split_diffs
 }
 
+#[derive(Clone, Copy)]
+struct SectionDiffContext<'a> {
+    obj: &'a ObjInfo,
+    diff: &'a ObjDiff,
+    section_index: Option<usize>,
+}
+
+impl<'a> SectionDiffContext<'a> {
+    pub fn new(obj: Option<&'a (ObjInfo, ObjDiff)>, section_name: Option<&str>) -> Option<Self> {
+        obj.map(|(obj, diff)| Self {
+            obj,
+            diff,
+            section_index: section_name.and_then(|section_name| find_section(obj, section_name)),
+        })
+    }
+
+    #[inline]
+    pub fn has_section(&self) -> bool { self.section_index.is_some() }
+}
+
 fn data_table_ui(
-    table: TableBuilder<'_>,
-    left_obj: Option<&(ObjInfo, ObjDiff)>,
-    right_obj: Option<&(ObjInfo, ObjDiff)>,
-    selected_symbol: &SymbolRefByName,
+    ui: &mut egui::Ui,
+    available_width: f32,
+    left_ctx: Option<SectionDiffContext<'_>>,
+    right_ctx: Option<SectionDiffContext<'_>>,
     config: &Appearance,
 ) -> Option<()> {
-    let left_section = left_obj.and_then(|(obj, diff)| {
-        find_section(obj, selected_symbol).map(|i| (&obj.sections[i], &diff.sections[i]))
-    });
-    let right_section = right_obj.and_then(|(obj, diff)| {
-        find_section(obj, selected_symbol).map(|i| (&obj.sections[i], &diff.sections[i]))
-    });
-
+    let left_section = left_ctx
+        .and_then(|ctx| ctx.section_index.map(|i| (&ctx.obj.sections[i], &ctx.diff.sections[i])));
+    let right_section = right_ctx
+        .and_then(|ctx| ctx.section_index.map(|i| (&ctx.obj.sections[i], &ctx.diff.sections[i])));
     let total_bytes = left_section
         .or(right_section)?
         .1
@@ -159,118 +176,117 @@ fn data_table_ui(
     let left_diffs = left_section.map(|(_, section)| split_diffs(&section.data_diff));
     let right_diffs = right_section.map(|(_, section)| split_diffs(&section.data_diff));
 
-    table.body(|body| {
-        body.rows(config.code_font.size, total_rows, |mut row| {
-            let row_index = row.index();
-            let address = row_index * BYTES_PER_ROW;
-            row.col(|ui| {
+    render_table(ui, available_width, 2, config.code_font.size, total_rows, |row, column| {
+        let i = row.index();
+        let address = i * BYTES_PER_ROW;
+        row.col(|ui| {
+            if column == 0 {
                 if let Some(left_diffs) = &left_diffs {
-                    data_row_ui(ui, address, &left_diffs[row_index], config);
+                    data_row_ui(ui, address, &left_diffs[i], config);
                 }
-            });
-            row.col(|ui| {
+            } else if column == 1 {
                 if let Some(right_diffs) = &right_diffs {
-                    data_row_ui(ui, address, &right_diffs[row_index], config);
+                    data_row_ui(ui, address, &right_diffs[i], config);
                 }
-            });
+            }
         });
     });
     Some(())
 }
 
-pub fn data_diff_ui(ui: &mut egui::Ui, state: &mut DiffViewState, appearance: &Appearance) {
-    let (Some(result), Some(selected_symbol)) = (&state.build, &state.symbol_state.selected_symbol)
-    else {
-        return;
+#[must_use]
+pub fn data_diff_ui(
+    ui: &mut egui::Ui,
+    state: &DiffViewState,
+    appearance: &Appearance,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
+    let Some(result) = &state.build else {
+        return ret;
     };
 
+    let section_name =
+        state.symbol_state.left_symbol.as_ref().and_then(|s| s.section_name.as_deref()).or_else(
+            || state.symbol_state.right_symbol.as_ref().and_then(|s| s.section_name.as_deref()),
+        );
+    let left_ctx = SectionDiffContext::new(result.first_obj.as_ref(), section_name);
+    let right_ctx = SectionDiffContext::new(result.second_obj.as_ref(), section_name);
+
+    // If both sides are missing a symbol, switch to symbol diff view
+    if !right_ctx.map_or(false, |ctx| ctx.has_section())
+        && !left_ctx.map_or(false, |ctx| ctx.has_section())
+    {
+        return Some(DiffViewAction::Navigate(DiffViewNavigation::symbol_diff()));
+    }
+
     // Header
     let available_width = ui.available_width();
-    let column_width = available_width / 2.0;
-    ui.allocate_ui_with_layout(
-        Vec2 { x: available_width, y: 100.0 },
-        Layout::left_to_right(Align::Min),
-        |ui| {
-            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
-
+    render_header(ui, available_width, 2, |ui, column| {
+        if column == 0 {
             // Left column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    if ui.button("⏴ Back").clicked() {
-                        state.current_view = View::SymbolDiff;
-                    }
-
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        ui.colored_label(appearance.highlight_color, &selected_symbol.symbol_name);
-                        ui.label("Diff target:");
-                    });
-                },
-            );
+            if ui.button("⏴ Back").clicked() {
+                ret = Some(DiffViewAction::Navigate(DiffViewNavigation::symbol_diff()));
+            }
 
+            if let Some(section) =
+                left_ctx.and_then(|ctx| ctx.section_index.map(|i| &ctx.obj.sections[i]))
+            {
+                ui.label(
+                    RichText::new(section.name.clone())
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+            } else {
+                ui.label(
+                    RichText::new("Missing")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.replace_color),
+                );
+            }
+        } else if column == 1 {
             // Right column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.horizontal(|ui| {
-                        if ui
-                            .add_enabled(!state.build_running, egui::Button::new("Build"))
-                            .clicked()
-                        {
-                            state.queue_build = true;
-                        }
-                        ui.scope(|ui| {
-                            ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                            if state.build_running {
-                                ui.colored_label(appearance.replace_color, "Building…");
-                            } else {
-                                ui.label("Last built:");
-                                let format =
-                                    format_description::parse("[hour]:[minute]:[second]").unwrap();
-                                ui.label(
-                                    result
-                                        .time
-                                        .to_offset(appearance.utc_offset)
-                                        .format(&format)
-                                        .unwrap(),
-                                );
-                            }
-                        });
-                    });
+            ui.horizontal(|ui| {
+                if ui.add_enabled(!state.build_running, egui::Button::new("Build")).clicked() {
+                    ret = Some(DiffViewAction::Build);
+                }
+                ui.scope(|ui| {
+                    ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
+                    if state.build_running {
+                        ui.colored_label(appearance.replace_color, "Building…");
+                    } else {
+                        ui.label("Last built:");
+                        let format = format_description::parse("[hour]:[minute]:[second]").unwrap();
+                        ui.label(
+                            result.time.to_offset(appearance.utc_offset).format(&format).unwrap(),
+                        );
+                    }
+                });
+            });
 
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        ui.label("");
-                        ui.label("Diff base:");
-                    });
-                },
-            );
-        },
-    );
-    ui.separator();
+            if let Some(section) =
+                right_ctx.and_then(|ctx| ctx.section_index.map(|i| &ctx.obj.sections[i]))
+            {
+                ui.label(
+                    RichText::new(section.name.clone())
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+            } else {
+                ui.label(
+                    RichText::new("Missing")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.replace_color),
+                );
+            }
+        }
+    });
 
     // Table
-    ui.style_mut().interaction.selectable_labels = false;
-    let available_height = ui.available_height();
-    let table = TableBuilder::new(ui)
-        .striped(false)
-        .cell_layout(Layout::left_to_right(Align::Min))
-        .columns(Column::exact(column_width).clip(true), 2)
-        .resizable(false)
-        .auto_shrink([false, false])
-        .min_scrolled_height(available_height);
-    data_table_ui(
-        table,
-        result.first_obj.as_ref(),
-        result.second_obj.as_ref(),
-        selected_symbol,
-        appearance,
-    );
+    let id =
+        Id::new(state.symbol_state.left_symbol.as_ref().and_then(|s| s.section_name.as_deref()))
+            .with(state.symbol_state.right_symbol.as_ref().and_then(|s| s.section_name.as_deref()));
+    ui.push_id(id, |ui| {
+        data_table_ui(ui, available_width, left_ctx, right_ctx, appearance);
+    });
+    ret
 }
diff --git a/objdiff-gui/src/views/extab_diff.rs b/objdiff-gui/src/views/extab_diff.rs
index 8372b3e..b91cd62 100644
--- a/objdiff-gui/src/views/extab_diff.rs
+++ b/objdiff-gui/src/views/extab_diff.rs
@@ -1,28 +1,20 @@
-use egui::{Align, Layout, ScrollArea, Ui, Vec2};
-use egui_extras::{Size, StripBuilder};
+use egui::{RichText, ScrollArea};
 use objdiff_core::{
     arch::ppc::ExceptionInfo,
-    diff::ObjDiff,
-    obj::{ObjInfo, ObjSymbol, SymbolRef},
+    obj::{ObjInfo, ObjSymbol},
 };
 use time::format_description;
 
 use crate::views::{
     appearance::Appearance,
-    symbol_diff::{match_color_for_symbol, DiffViewState, SymbolRefByName, View},
+    column_layout::{render_header, render_strips},
+    function_diff::FunctionDiffContext,
+    symbol_diff::{
+        match_color_for_symbol, DiffViewAction, DiffViewNavigation, DiffViewState, SymbolRefByName,
+        View,
+    },
 };
 
-fn find_symbol(obj: &ObjInfo, selected_symbol: &SymbolRefByName) -> Option<SymbolRef> {
-    for (section_idx, section) in obj.sections.iter().enumerate() {
-        for (symbol_idx, symbol) in section.symbols.iter().enumerate() {
-            if symbol.name == selected_symbol.symbol_name {
-                return Some(SymbolRef { section_idx, symbol_idx });
-            }
-        }
-    }
-    None
-}
-
 fn decode_extab(extab: &ExceptionInfo) -> String {
     let mut text = String::from("");
 
@@ -48,14 +40,12 @@ fn find_extab_entry<'a>(obj: &'a ObjInfo, symbol: &ObjSymbol) -> Option<&'a Exce
 }
 
 fn extab_text_ui(
-    ui: &mut Ui,
-    obj: &(ObjInfo, ObjDiff),
-    symbol_ref: SymbolRef,
+    ui: &mut egui::Ui,
+    ctx: FunctionDiffContext<'_>,
+    symbol: &ObjSymbol,
     appearance: &Appearance,
 ) -> Option<()> {
-    let (_section, symbol) = obj.0.section_symbol(symbol_ref);
-
-    if let Some(extab_entry) = find_extab_entry(&obj.0, symbol) {
+    if let Some(extab_entry) = find_extab_entry(ctx.obj, symbol) {
         let text = decode_extab(extab_entry);
         ui.colored_label(appearance.replace_color, &text);
         return Some(());
@@ -65,137 +55,194 @@ fn extab_text_ui(
 }
 
 fn extab_ui(
-    ui: &mut Ui,
-    obj: Option<&(ObjInfo, ObjDiff)>,
-    selected_symbol: &SymbolRefByName,
+    ui: &mut egui::Ui,
+    ctx: FunctionDiffContext<'_>,
     appearance: &Appearance,
-    _left: bool,
+    _column: usize,
 ) {
     ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
         ui.scope(|ui| {
             ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
             ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
 
-            let symbol = obj.and_then(|(obj, _)| find_symbol(obj, selected_symbol));
-
-            if let (Some(object), Some(symbol_ref)) = (obj, symbol) {
-                extab_text_ui(ui, object, symbol_ref, appearance);
+            if let Some((_section, symbol)) =
+                ctx.symbol_ref.map(|symbol_ref| ctx.obj.section_symbol(symbol_ref))
+            {
+                extab_text_ui(ui, ctx, symbol, appearance);
             }
         });
     });
 }
 
-pub fn extab_diff_ui(ui: &mut egui::Ui, state: &mut DiffViewState, appearance: &Appearance) {
-    let (Some(result), Some(selected_symbol)) = (&state.build, &state.symbol_state.selected_symbol)
-    else {
-        return;
+#[must_use]
+pub fn extab_diff_ui(
+    ui: &mut egui::Ui,
+    state: &DiffViewState,
+    appearance: &Appearance,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
+    let Some(result) = &state.build else {
+        return ret;
     };
 
+    let mut left_ctx = FunctionDiffContext::new(
+        result.first_obj.as_ref(),
+        state.symbol_state.left_symbol.as_ref(),
+    );
+    let mut right_ctx = FunctionDiffContext::new(
+        result.second_obj.as_ref(),
+        state.symbol_state.right_symbol.as_ref(),
+    );
+
+    // If one side is missing a symbol, but the diff process found a match, use that symbol
+    let left_diff_symbol = left_ctx.and_then(|ctx| {
+        ctx.symbol_ref.and_then(|symbol_ref| ctx.diff.symbol_diff(symbol_ref).target_symbol)
+    });
+    let right_diff_symbol = right_ctx.and_then(|ctx| {
+        ctx.symbol_ref.and_then(|symbol_ref| ctx.diff.symbol_diff(symbol_ref).target_symbol)
+    });
+    if left_diff_symbol.is_some() && right_ctx.map_or(false, |ctx| !ctx.has_symbol()) {
+        let (right_section, right_symbol) =
+            right_ctx.unwrap().obj.section_symbol(left_diff_symbol.unwrap());
+        let symbol_ref = SymbolRefByName::new(right_symbol, right_section);
+        right_ctx = FunctionDiffContext::new(result.second_obj.as_ref(), Some(&symbol_ref));
+        ret = Some(DiffViewAction::Navigate(DiffViewNavigation {
+            view: Some(View::FunctionDiff),
+            left_symbol: state.symbol_state.left_symbol.clone(),
+            right_symbol: Some(symbol_ref),
+        }));
+    } else if right_diff_symbol.is_some() && left_ctx.map_or(false, |ctx| !ctx.has_symbol()) {
+        let (left_section, left_symbol) =
+            left_ctx.unwrap().obj.section_symbol(right_diff_symbol.unwrap());
+        let symbol_ref = SymbolRefByName::new(left_symbol, left_section);
+        left_ctx = FunctionDiffContext::new(result.first_obj.as_ref(), Some(&symbol_ref));
+        ret = Some(DiffViewAction::Navigate(DiffViewNavigation {
+            view: Some(View::FunctionDiff),
+            left_symbol: Some(symbol_ref),
+            right_symbol: state.symbol_state.right_symbol.clone(),
+        }));
+    }
+
+    // If both sides are missing a symbol, switch to symbol diff view
+    if right_ctx.map_or(false, |ctx| !ctx.has_symbol())
+        && left_ctx.map_or(false, |ctx| !ctx.has_symbol())
+    {
+        return Some(DiffViewAction::Navigate(DiffViewNavigation::symbol_diff()));
+    }
+
     // Header
     let available_width = ui.available_width();
-    let column_width = available_width / 2.0;
-    ui.allocate_ui_with_layout(
-        Vec2 { x: available_width, y: 100.0 },
-        Layout::left_to_right(Align::Min),
-        |ui| {
-            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
-
+    render_header(ui, available_width, 2, |ui, column| {
+        if column == 0 {
             // Left column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.horizontal(|ui| {
-                        if ui.button("⏴ Back").clicked() {
-                            state.current_view = View::SymbolDiff;
-                        }
-                    });
-
-                    let name = selected_symbol
-                        .demangled_symbol_name
-                        .as_deref()
-                        .unwrap_or(&selected_symbol.symbol_name);
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        ui.colored_label(appearance.highlight_color, name);
-                        ui.label("Diff target:");
-                    });
-                },
-            );
+            ui.horizontal(|ui| {
+                if ui.button("⏴ Back").clicked() {
+                    ret = Some(DiffViewAction::Navigate(DiffViewNavigation::symbol_diff()));
+                }
+                ui.separator();
+                if ui
+                    .add_enabled(
+                        !state.scratch_running
+                            && state.scratch_available
+                            && left_ctx.map_or(false, |ctx| ctx.has_symbol()),
+                        egui::Button::new("📲 decomp.me"),
+                    )
+                    .on_hover_text_at_pointer("Create a new scratch on decomp.me (beta)")
+                    .on_disabled_hover_text("Scratch configuration missing")
+                    .clicked()
+                {
+                    if let Some((_section, symbol)) = left_ctx.and_then(|ctx| {
+                        ctx.symbol_ref.map(|symbol_ref| ctx.obj.section_symbol(symbol_ref))
+                    }) {
+                        ret = Some(DiffViewAction::CreateScratch(symbol.name.clone()));
+                    }
+                }
+            });
 
+            if let Some((_section, symbol)) = left_ctx
+                .and_then(|ctx| ctx.symbol_ref.map(|symbol_ref| ctx.obj.section_symbol(symbol_ref)))
+            {
+                let name = symbol.demangled_name.as_deref().unwrap_or(&symbol.name);
+                ui.label(
+                    RichText::new(name)
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+            } else {
+                ui.label(
+                    RichText::new("Missing")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.replace_color),
+                );
+            }
+        } else if column == 1 {
             // Right column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.horizontal(|ui| {
-                        if ui
-                            .add_enabled(!state.build_running, egui::Button::new("Build"))
-                            .clicked()
-                        {
-                            state.queue_build = true;
-                        }
-                        ui.scope(|ui| {
-                            ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                            if state.build_running {
-                                ui.colored_label(appearance.replace_color, "Building…");
-                            } else {
-                                ui.label("Last built:");
-                                let format =
-                                    format_description::parse("[hour]:[minute]:[second]").unwrap();
-                                ui.label(
-                                    result
-                                        .time
-                                        .to_offset(appearance.utc_offset)
-                                        .format(&format)
-                                        .unwrap(),
-                                );
-                            }
-                        });
-                    });
-
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        if let Some(match_percent) = result
-                            .second_obj
-                            .as_ref()
-                            .and_then(|(obj, diff)| {
-                                find_symbol(obj, selected_symbol).map(|sref| {
-                                    &diff.sections[sref.section_idx].symbols[sref.symbol_idx]
-                                })
-                            })
-                            .and_then(|symbol| symbol.match_percent)
-                        {
-                            ui.colored_label(
-                                match_color_for_symbol(match_percent, appearance),
-                                format!("{:.0}%", match_percent.floor()),
-                            );
-                        } else {
-                            ui.colored_label(appearance.replace_color, "Missing");
-                        }
-                        ui.label("Diff base:");
-                    });
-                },
-            );
-        },
-    );
-    ui.separator();
-
-    // Table
-    StripBuilder::new(ui).size(Size::remainder()).vertical(|mut strip| {
-        strip.strip(|builder| {
-            builder.sizes(Size::remainder(), 2).horizontal(|mut strip| {
-                strip.cell(|ui| {
-                    extab_ui(ui, result.first_obj.as_ref(), selected_symbol, appearance, true);
-                });
-                strip.cell(|ui| {
-                    extab_ui(ui, result.second_obj.as_ref(), selected_symbol, appearance, false);
+            ui.horizontal(|ui| {
+                if ui.add_enabled(!state.build_running, egui::Button::new("Build")).clicked() {
+                    ret = Some(DiffViewAction::Build);
+                }
+                ui.scope(|ui| {
+                    ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
+                    if state.build_running {
+                        ui.colored_label(appearance.replace_color, "Building…");
+                    } else {
+                        ui.label("Last built:");
+                        let format = format_description::parse("[hour]:[minute]:[second]").unwrap();
+                        ui.label(
+                            result.time.to_offset(appearance.utc_offset).format(&format).unwrap(),
+                        );
+                    }
                 });
+                ui.separator();
+                if ui
+                    .add_enabled(state.source_path_available, egui::Button::new("🖹 Source file"))
+                    .on_hover_text_at_pointer("Open the source file in the default editor")
+                    .on_disabled_hover_text("Source file metadata missing")
+                    .clicked()
+                {
+                    ret = Some(DiffViewAction::OpenSourcePath);
+                }
             });
-        });
+
+            if let Some(((_section, symbol), symbol_diff)) = right_ctx.and_then(|ctx| {
+                ctx.symbol_ref.map(|symbol_ref| {
+                    (ctx.obj.section_symbol(symbol_ref), ctx.diff.symbol_diff(symbol_ref))
+                })
+            }) {
+                let name = symbol.demangled_name.as_deref().unwrap_or(&symbol.name);
+                ui.label(
+                    RichText::new(name)
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+                if let Some(match_percent) = symbol_diff.match_percent {
+                    ui.label(
+                        RichText::new(format!("{:.0}%", match_percent.floor()))
+                            .font(appearance.code_font.clone())
+                            .color(match_color_for_symbol(match_percent, appearance)),
+                    );
+                }
+            } else {
+                ui.label(
+                    RichText::new("Missing")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.replace_color),
+                );
+            }
+        }
+    });
+
+    // Table
+    render_strips(ui, available_width, 2, |ui, column| {
+        if column == 0 {
+            if let Some(ctx) = left_ctx {
+                extab_ui(ui, ctx, appearance, column);
+            }
+        } else if column == 1 {
+            if let Some(ctx) = right_ctx {
+                extab_ui(ui, ctx, appearance, column);
+            }
+        }
     });
+    ret
 }
diff --git a/objdiff-gui/src/views/function_diff.rs b/objdiff-gui/src/views/function_diff.rs
index 65cfd2c..d372220 100644
--- a/objdiff-gui/src/views/function_diff.rs
+++ b/objdiff-gui/src/views/function_diff.rs
@@ -1,28 +1,29 @@
 use std::default::Default;
 
-use egui::{text::LayoutJob, Align, Label, Layout, Response, Sense, Vec2, Widget};
-use egui_extras::{Column, TableBuilder, TableRow};
+use egui::{text::LayoutJob, Id, Label, Response, RichText, Sense, Widget};
+use egui_extras::TableRow;
 use objdiff_core::{
     arch::ObjArch,
     diff::{
         display::{display_diff, DiffText, HighlightKind},
         ObjDiff, ObjInsDiff, ObjInsDiffKind,
     },
-    obj::{ObjInfo, ObjIns, ObjInsArg, ObjInsArgValue, ObjSection, ObjSymbol, SymbolRef},
+    obj::{
+        ObjInfo, ObjIns, ObjInsArg, ObjInsArgValue, ObjSection, ObjSectionKind, ObjSymbol,
+        SymbolRef,
+    },
 };
 use time::format_description;
 
 use crate::views::{
     appearance::Appearance,
-    symbol_diff::{match_color_for_symbol, DiffViewState, SymbolRefByName, View},
+    column_layout::{render_header, render_strips, render_table},
+    symbol_diff::{
+        match_color_for_symbol, symbol_list_ui, DiffViewAction, DiffViewNavigation, DiffViewState,
+        SymbolDiffContext, SymbolFilter, SymbolRefByName, SymbolViewState, View,
+    },
 };
 
-#[derive(Copy, Clone, Eq, PartialEq)]
-enum ColumnId {
-    Left,
-    Right,
-}
-
 #[derive(Default)]
 pub struct FunctionViewState {
     left_highlight: HighlightKind,
@@ -30,16 +31,17 @@ pub struct FunctionViewState {
 }
 
 impl FunctionViewState {
-    fn highlight(&self, column: ColumnId) -> &HighlightKind {
+    pub fn highlight(&self, column: usize) -> &HighlightKind {
         match column {
-            ColumnId::Left => &self.left_highlight,
-            ColumnId::Right => &self.right_highlight,
+            0 => &self.left_highlight,
+            1 => &self.right_highlight,
+            _ => &HighlightKind::None,
         }
     }
 
-    fn set_highlight(&mut self, column: ColumnId, highlight: HighlightKind) {
+    pub fn set_highlight(&mut self, column: usize, highlight: HighlightKind) {
         match column {
-            ColumnId::Left => {
+            0 => {
                 if highlight == self.left_highlight {
                     if highlight == self.right_highlight {
                         self.left_highlight = HighlightKind::None;
@@ -51,7 +53,7 @@ impl FunctionViewState {
                     self.left_highlight = highlight;
                 }
             }
-            ColumnId::Right => {
+            1 => {
                 if highlight == self.right_highlight {
                     if highlight == self.left_highlight {
                         self.left_highlight = HighlightKind::None;
@@ -63,10 +65,11 @@ impl FunctionViewState {
                     self.right_highlight = highlight;
                 }
             }
+            _ => {}
         }
     }
 
-    fn clear_highlight(&mut self) {
+    pub fn clear_highlight(&mut self) {
         self.left_highlight = HighlightKind::None;
         self.right_highlight = HighlightKind::None;
     }
@@ -223,17 +226,19 @@ fn find_symbol(obj: &ObjInfo, selected_symbol: &SymbolRefByName) -> Option<Symbo
     None
 }
 
-#[allow(clippy::too_many_arguments)]
+#[must_use]
+#[expect(clippy::too_many_arguments)]
 fn diff_text_ui(
     ui: &mut egui::Ui,
     text: DiffText<'_>,
     ins_diff: &ObjInsDiff,
     appearance: &Appearance,
-    ins_view_state: &mut FunctionViewState,
-    column: ColumnId,
+    ins_view_state: &FunctionViewState,
+    column: usize,
     space_width: f32,
     response_cb: impl Fn(Response) -> Response,
-) {
+) -> Option<DiffViewAction> {
+    let mut ret = None;
     let label_text;
     let mut base_color = match ins_diff.kind {
         ObjInsDiffKind::None | ObjInsDiffKind::OpMismatch | ObjInsDiffKind::ArgMismatch => {
@@ -287,7 +292,7 @@ fn diff_text_ui(
         }
         DiffText::Spacing(n) => {
             ui.add_space(n as f32 * space_width);
-            return;
+            return ret;
         }
         DiffText::Eol => {
             label_text = "\n".to_string();
@@ -304,22 +309,25 @@ fn diff_text_ui(
     .ui(ui);
     response = response_cb(response);
     if response.clicked() {
-        ins_view_state.set_highlight(column, text.into());
+        ret = Some(DiffViewAction::SetDiffHighlight(column, text.into()));
     }
     if len < pad_to {
         ui.add_space((pad_to - len) as f32 * space_width);
     }
+    ret
 }
 
+#[must_use]
 fn asm_row_ui(
     ui: &mut egui::Ui,
     ins_diff: &ObjInsDiff,
     symbol: &ObjSymbol,
     appearance: &Appearance,
-    ins_view_state: &mut FunctionViewState,
-    column: ColumnId,
+    ins_view_state: &FunctionViewState,
+    column: usize,
     response_cb: impl Fn(Response) -> Response,
-) {
+) -> Option<DiffViewAction> {
+    let mut ret = None;
     ui.spacing_mut().item_spacing.x = 0.0;
     ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
     if ins_diff.kind != ObjInsDiffKind::None {
@@ -327,7 +335,7 @@ fn asm_row_ui(
     }
     let space_width = ui.fonts(|f| f.glyph_width(&appearance.code_font, ' '));
     display_diff(ins_diff, symbol.address, |text| {
-        diff_text_ui(
+        if let Some(action) = diff_text_ui(
             ui,
             text,
             ins_diff,
@@ -336,246 +344,476 @@ fn asm_row_ui(
             column,
             space_width,
             &response_cb,
-        );
+        ) {
+            ret = Some(action);
+        }
         Ok::<_, ()>(())
     })
     .unwrap();
+    ret
 }
 
+#[must_use]
 fn asm_col_ui(
     row: &mut TableRow<'_, '_>,
-    obj: &(ObjInfo, ObjDiff),
-    symbol_ref: SymbolRef,
+    ctx: FunctionDiffContext<'_>,
     appearance: &Appearance,
-    ins_view_state: &mut FunctionViewState,
-    column: ColumnId,
-) {
-    let (section, symbol) = obj.0.section_symbol(symbol_ref);
-    let section = section.unwrap();
-    let ins_diff = &obj.1.symbol_diff(symbol_ref).instructions[row.index()];
+    ins_view_state: &FunctionViewState,
+    column: usize,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
+    let symbol_ref = ctx.symbol_ref?;
+    let (section, symbol) = ctx.obj.section_symbol(symbol_ref);
+    let section = section?;
+    let ins_diff = &ctx.diff.symbol_diff(symbol_ref).instructions[row.index()];
     let response_cb = |response: Response| {
         if let Some(ins) = &ins_diff.ins {
             response.context_menu(|ui| ins_context_menu(ui, section, ins, symbol));
             response.on_hover_ui_at_pointer(|ui| {
-                ins_hover_ui(ui, obj.0.arch.as_ref(), section, ins, symbol, appearance)
+                ins_hover_ui(ui, ctx.obj.arch.as_ref(), section, ins, symbol, appearance)
             })
         } else {
             response
         }
     };
     let (_, response) = row.col(|ui| {
-        asm_row_ui(ui, ins_diff, symbol, appearance, ins_view_state, column, response_cb);
+        if let Some(action) =
+            asm_row_ui(ui, ins_diff, symbol, appearance, ins_view_state, column, response_cb)
+        {
+            ret = Some(action);
+        }
     });
     response_cb(response);
+    ret
 }
 
-fn empty_col_ui(row: &mut TableRow<'_, '_>) {
-    row.col(|ui| {
-        ui.label("");
-    });
-}
-
+#[must_use]
 fn asm_table_ui(
-    table: TableBuilder<'_>,
-    left_obj: Option<&(ObjInfo, ObjDiff)>,
-    right_obj: Option<&(ObjInfo, ObjDiff)>,
-    selected_symbol: &SymbolRefByName,
+    ui: &mut egui::Ui,
+    available_width: f32,
+    left_ctx: Option<FunctionDiffContext<'_>>,
+    right_ctx: Option<FunctionDiffContext<'_>>,
     appearance: &Appearance,
-    ins_view_state: &mut FunctionViewState,
-) -> Option<()> {
-    let left_symbol = left_obj.and_then(|(obj, _)| find_symbol(obj, selected_symbol));
-    let right_symbol = right_obj.and_then(|(obj, _)| find_symbol(obj, selected_symbol));
-    let instructions_len = match (left_symbol, right_symbol) {
-        (Some(left_symbol_ref), Some(right_symbol_ref)) => {
-            let left_len = left_obj.unwrap().1.symbol_diff(left_symbol_ref).instructions.len();
-            let right_len = right_obj.unwrap().1.symbol_diff(right_symbol_ref).instructions.len();
-            debug_assert_eq!(left_len, right_len);
+    ins_view_state: &FunctionViewState,
+    symbol_state: &SymbolViewState,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
+    let left_len = left_ctx.and_then(|ctx| {
+        ctx.symbol_ref.map(|symbol_ref| ctx.diff.symbol_diff(symbol_ref).instructions.len())
+    });
+    let right_len = right_ctx.and_then(|ctx| {
+        ctx.symbol_ref.map(|symbol_ref| ctx.diff.symbol_diff(symbol_ref).instructions.len())
+    });
+    let instructions_len = match (left_len, right_len) {
+        (Some(left_len), Some(right_len)) => {
+            if left_len != right_len {
+                ui.label("Instruction count mismatch");
+                return None;
+            }
             left_len
         }
-        (Some(left_symbol_ref), None) => {
-            left_obj.unwrap().1.symbol_diff(left_symbol_ref).instructions.len()
+        (Some(left_len), None) => left_len,
+        (None, Some(right_len)) => right_len,
+        (None, None) => {
+            ui.label("No symbol selected");
+            return None;
         }
-        (None, Some(right_symbol_ref)) => {
-            right_obj.unwrap().1.symbol_diff(right_symbol_ref).instructions.len()
-        }
-        (None, None) => return None,
     };
-    table.body(|body| {
-        body.rows(appearance.code_font.size, instructions_len, |mut row| {
-            row.set_hovered(false); // Disable row hover effect
-            if let (Some(left_obj), Some(left_symbol_ref)) = (left_obj, left_symbol) {
-                asm_col_ui(
-                    &mut row,
-                    left_obj,
-                    left_symbol_ref,
-                    appearance,
-                    ins_view_state,
-                    ColumnId::Left,
-                );
-            } else {
-                empty_col_ui(&mut row);
-            }
-            if let (Some(right_obj), Some(right_symbol_ref)) = (right_obj, right_symbol) {
-                asm_col_ui(
-                    &mut row,
-                    right_obj,
-                    right_symbol_ref,
-                    appearance,
-                    ins_view_state,
-                    ColumnId::Right,
-                );
-            } else {
-                empty_col_ui(&mut row);
-            }
-            if row.response().clicked() {
-                ins_view_state.clear_highlight();
+    if left_len.is_some() && right_len.is_some() {
+        // Joint view
+        render_table(
+            ui,
+            available_width,
+            2,
+            appearance.code_font.size,
+            instructions_len,
+            |row, column| {
+                if column == 0 {
+                    if let Some(ctx) = left_ctx {
+                        if let Some(action) =
+                            asm_col_ui(row, ctx, appearance, ins_view_state, column)
+                        {
+                            ret = Some(action);
+                        }
+                    }
+                } else if column == 1 {
+                    if let Some(ctx) = right_ctx {
+                        if let Some(action) =
+                            asm_col_ui(row, ctx, appearance, ins_view_state, column)
+                        {
+                            ret = Some(action);
+                        }
+                    }
+                    if row.response().clicked() {
+                        ret = Some(DiffViewAction::ClearDiffHighlight);
+                    }
+                }
+            },
+        );
+    } else {
+        // Split view, one side is the symbol list
+        render_strips(ui, available_width, 2, |ui, column| {
+            if column == 0 {
+                if let Some(ctx) = left_ctx {
+                    if ctx.has_symbol() {
+                        render_table(
+                            ui,
+                            available_width / 2.0,
+                            1,
+                            appearance.code_font.size,
+                            instructions_len,
+                            |row, column| {
+                                if let Some(action) =
+                                    asm_col_ui(row, ctx, appearance, ins_view_state, column)
+                                {
+                                    ret = Some(action);
+                                }
+                                if row.response().clicked() {
+                                    ret = Some(DiffViewAction::ClearDiffHighlight);
+                                }
+                            },
+                        );
+                    } else if let Some((right_ctx, right_symbol_ref)) =
+                        right_ctx.and_then(|ctx| ctx.symbol_ref.map(|symbol_ref| (ctx, symbol_ref)))
+                    {
+                        if let Some(action) = symbol_list_ui(
+                            ui,
+                            SymbolDiffContext { obj: ctx.obj, diff: ctx.diff },
+                            None,
+                            symbol_state,
+                            SymbolFilter::Mapping(right_symbol_ref),
+                            appearance,
+                            column,
+                        ) {
+                            match action {
+                                DiffViewAction::Navigate(DiffViewNavigation {
+                                    left_symbol: Some(left_symbol_ref),
+                                    ..
+                                }) => {
+                                    let (right_section, right_symbol) =
+                                        right_ctx.obj.section_symbol(right_symbol_ref);
+                                    ret = Some(DiffViewAction::SetMapping(
+                                        match right_section.map(|s| s.kind) {
+                                            Some(ObjSectionKind::Code) => View::FunctionDiff,
+                                            _ => View::SymbolDiff,
+                                        },
+                                        left_symbol_ref,
+                                        SymbolRefByName::new(right_symbol, right_section),
+                                    ));
+                                }
+                                DiffViewAction::SetSymbolHighlight(_, _) => {
+                                    // Ignore
+                                }
+                                _ => {
+                                    ret = Some(action);
+                                }
+                            }
+                        }
+                    }
+                } else {
+                    ui.label("No left object");
+                }
+            } else if column == 1 {
+                if let Some(ctx) = right_ctx {
+                    if ctx.has_symbol() {
+                        render_table(
+                            ui,
+                            available_width / 2.0,
+                            1,
+                            appearance.code_font.size,
+                            instructions_len,
+                            |row, column| {
+                                if let Some(action) =
+                                    asm_col_ui(row, ctx, appearance, ins_view_state, column)
+                                {
+                                    ret = Some(action);
+                                }
+                                if row.response().clicked() {
+                                    ret = Some(DiffViewAction::ClearDiffHighlight);
+                                }
+                            },
+                        );
+                    } else if let Some((left_ctx, left_symbol_ref)) =
+                        left_ctx.and_then(|ctx| ctx.symbol_ref.map(|symbol_ref| (ctx, symbol_ref)))
+                    {
+                        if let Some(action) = symbol_list_ui(
+                            ui,
+                            SymbolDiffContext { obj: ctx.obj, diff: ctx.diff },
+                            None,
+                            symbol_state,
+                            SymbolFilter::Mapping(left_symbol_ref),
+                            appearance,
+                            column,
+                        ) {
+                            match action {
+                                DiffViewAction::Navigate(DiffViewNavigation {
+                                    right_symbol: Some(right_symbol_ref),
+                                    ..
+                                }) => {
+                                    let (left_section, left_symbol) =
+                                        left_ctx.obj.section_symbol(left_symbol_ref);
+                                    ret = Some(DiffViewAction::SetMapping(
+                                        match left_section.map(|s| s.kind) {
+                                            Some(ObjSectionKind::Code) => View::FunctionDiff,
+                                            _ => View::SymbolDiff,
+                                        },
+                                        SymbolRefByName::new(left_symbol, left_section),
+                                        right_symbol_ref,
+                                    ));
+                                }
+                                DiffViewAction::SetSymbolHighlight(_, _) => {
+                                    // Ignore
+                                }
+                                _ => {
+                                    ret = Some(action);
+                                }
+                            }
+                        }
+                    }
+                } else {
+                    ui.label("No right object");
+                }
             }
         });
-    });
-    Some(())
+    }
+    ret
+}
+
+#[derive(Clone, Copy)]
+pub struct FunctionDiffContext<'a> {
+    pub obj: &'a ObjInfo,
+    pub diff: &'a ObjDiff,
+    pub symbol_ref: Option<SymbolRef>,
+}
+
+impl<'a> FunctionDiffContext<'a> {
+    pub fn new(
+        obj: Option<&'a (ObjInfo, ObjDiff)>,
+        selected_symbol: Option<&SymbolRefByName>,
+    ) -> Option<Self> {
+        obj.map(|(obj, diff)| Self {
+            obj,
+            diff,
+            symbol_ref: selected_symbol.and_then(|s| find_symbol(obj, s)),
+        })
+    }
+
+    #[inline]
+    pub fn has_symbol(&self) -> bool { self.symbol_ref.is_some() }
 }
 
-pub fn function_diff_ui(ui: &mut egui::Ui, state: &mut DiffViewState, appearance: &Appearance) {
-    let (Some(result), Some(selected_symbol)) = (&state.build, &state.symbol_state.selected_symbol)
-    else {
-        return;
+#[must_use]
+pub fn function_diff_ui(
+    ui: &mut egui::Ui,
+    state: &DiffViewState,
+    appearance: &Appearance,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
+    let Some(result) = &state.build else {
+        return ret;
     };
 
+    let mut left_ctx = FunctionDiffContext::new(
+        result.first_obj.as_ref(),
+        state.symbol_state.left_symbol.as_ref(),
+    );
+    let mut right_ctx = FunctionDiffContext::new(
+        result.second_obj.as_ref(),
+        state.symbol_state.right_symbol.as_ref(),
+    );
+
+    // If one side is missing a symbol, but the diff process found a match, use that symbol
+    let left_diff_symbol = left_ctx.and_then(|ctx| {
+        ctx.symbol_ref.and_then(|symbol_ref| ctx.diff.symbol_diff(symbol_ref).target_symbol)
+    });
+    let right_diff_symbol = right_ctx.and_then(|ctx| {
+        ctx.symbol_ref.and_then(|symbol_ref| ctx.diff.symbol_diff(symbol_ref).target_symbol)
+    });
+    if left_diff_symbol.is_some() && right_ctx.map_or(false, |ctx| !ctx.has_symbol()) {
+        let (right_section, right_symbol) =
+            right_ctx.unwrap().obj.section_symbol(left_diff_symbol.unwrap());
+        let symbol_ref = SymbolRefByName::new(right_symbol, right_section);
+        right_ctx = FunctionDiffContext::new(result.second_obj.as_ref(), Some(&symbol_ref));
+        ret = Some(DiffViewAction::Navigate(DiffViewNavigation {
+            view: Some(View::FunctionDiff),
+            left_symbol: state.symbol_state.left_symbol.clone(),
+            right_symbol: Some(symbol_ref),
+        }));
+    } else if right_diff_symbol.is_some() && left_ctx.map_or(false, |ctx| !ctx.has_symbol()) {
+        let (left_section, left_symbol) =
+            left_ctx.unwrap().obj.section_symbol(right_diff_symbol.unwrap());
+        let symbol_ref = SymbolRefByName::new(left_symbol, left_section);
+        left_ctx = FunctionDiffContext::new(result.first_obj.as_ref(), Some(&symbol_ref));
+        ret = Some(DiffViewAction::Navigate(DiffViewNavigation {
+            view: Some(View::FunctionDiff),
+            left_symbol: Some(symbol_ref),
+            right_symbol: state.symbol_state.right_symbol.clone(),
+        }));
+    }
+
+    // If both sides are missing a symbol, switch to symbol diff view
+    if right_ctx.map_or(false, |ctx| !ctx.has_symbol())
+        && left_ctx.map_or(false, |ctx| !ctx.has_symbol())
+    {
+        return Some(DiffViewAction::Navigate(DiffViewNavigation::symbol_diff()));
+    }
+
     // Header
     let available_width = ui.available_width();
-    let column_width = available_width / 2.0;
-    ui.allocate_ui_with_layout(
-        Vec2 { x: available_width, y: 100.0 },
-        Layout::left_to_right(Align::Min),
-        |ui| {
-            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
-
+    render_header(ui, available_width, 2, |ui, column| {
+        if column == 0 {
             // Left column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.horizontal(|ui| {
-                        if ui.button("⏴ Back").clicked() {
-                            state.current_view = View::SymbolDiff;
-                        }
-                        ui.separator();
-                        if ui
-                            .add_enabled(
-                                !state.scratch_running && state.scratch_available,
-                                egui::Button::new("📲 decomp.me"),
-                            )
-                            .on_hover_text_at_pointer("Create a new scratch on decomp.me (beta)")
-                            .on_disabled_hover_text("Scratch configuration missing")
-                            .clicked()
-                        {
-                            state.queue_scratch = true;
-                        }
-                    });
-
-                    let name = selected_symbol
-                        .demangled_symbol_name
-                        .as_deref()
-                        .unwrap_or(&selected_symbol.symbol_name);
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        ui.colored_label(appearance.highlight_color, name);
-                        ui.label("Diff target:");
-                    });
-                },
-            );
-
+            ui.horizontal(|ui| {
+                if ui.button("⏴ Back").clicked() {
+                    ret = Some(DiffViewAction::Navigate(DiffViewNavigation::symbol_diff()));
+                }
+                ui.separator();
+                if ui
+                    .add_enabled(
+                        !state.scratch_running
+                            && state.scratch_available
+                            && left_ctx.map_or(false, |ctx| ctx.has_symbol()),
+                        egui::Button::new("📲 decomp.me"),
+                    )
+                    .on_hover_text_at_pointer("Create a new scratch on decomp.me (beta)")
+                    .on_disabled_hover_text("Scratch configuration missing")
+                    .clicked()
+                {
+                    if let Some((_section, symbol)) = left_ctx.and_then(|ctx| {
+                        ctx.symbol_ref.map(|symbol_ref| ctx.obj.section_symbol(symbol_ref))
+                    }) {
+                        ret = Some(DiffViewAction::CreateScratch(symbol.name.clone()));
+                    }
+                }
+            });
+
+            if let Some((_section, symbol)) = left_ctx
+                .and_then(|ctx| ctx.symbol_ref.map(|symbol_ref| ctx.obj.section_symbol(symbol_ref)))
+            {
+                let name = symbol.demangled_name.as_deref().unwrap_or(&symbol.name);
+                ui.label(
+                    RichText::new(name)
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+                if right_ctx.map_or(false, |m| m.has_symbol())
+                    && ui
+                        .button("Change target")
+                        .on_hover_text_at_pointer("Choose a different symbol to use as the target")
+                        .clicked()
+                {
+                    if let Some(symbol_ref) = state.symbol_state.right_symbol.as_ref() {
+                        ret = Some(DiffViewAction::SelectingLeft(symbol_ref.clone()));
+                    }
+                }
+            } else {
+                ui.label(
+                    RichText::new("Missing")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.replace_color),
+                );
+                ui.label(
+                    RichText::new("Choose target symbol")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+            }
+        } else if column == 1 {
             // Right column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.horizontal(|ui| {
-                        if ui
-                            .add_enabled(!state.build_running, egui::Button::new("Build"))
-                            .clicked()
-                        {
-                            state.queue_build = true;
-                        }
-                        ui.scope(|ui| {
-                            ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                            if state.build_running {
-                                ui.colored_label(appearance.replace_color, "Building…");
-                            } else {
-                                ui.label("Last built:");
-                                let format =
-                                    format_description::parse("[hour]:[minute]:[second]").unwrap();
-                                ui.label(
-                                    result
-                                        .time
-                                        .to_offset(appearance.utc_offset)
-                                        .format(&format)
-                                        .unwrap(),
-                                );
-                            }
-                        });
+            ui.horizontal(|ui| {
+                if ui.add_enabled(!state.build_running, egui::Button::new("Build")).clicked() {
+                    ret = Some(DiffViewAction::Build);
+                }
+                ui.scope(|ui| {
+                    ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
+                    if state.build_running {
+                        ui.colored_label(appearance.replace_color, "Building…");
+                    } else {
+                        ui.label("Last built:");
+                        let format = format_description::parse("[hour]:[minute]:[second]").unwrap();
+                        ui.label(
+                            result.time.to_offset(appearance.utc_offset).format(&format).unwrap(),
+                        );
+                    }
+                });
+                ui.separator();
+                if ui
+                    .add_enabled(state.source_path_available, egui::Button::new("🖹 Source file"))
+                    .on_hover_text_at_pointer("Open the source file in the default editor")
+                    .on_disabled_hover_text("Source file metadata missing")
+                    .clicked()
+                {
+                    ret = Some(DiffViewAction::OpenSourcePath);
+                }
+            });
+
+            if let Some(((_section, symbol), symbol_diff)) = right_ctx.and_then(|ctx| {
+                ctx.symbol_ref.map(|symbol_ref| {
+                    (ctx.obj.section_symbol(symbol_ref), ctx.diff.symbol_diff(symbol_ref))
+                })
+            }) {
+                let name = symbol.demangled_name.as_deref().unwrap_or(&symbol.name);
+                ui.label(
+                    RichText::new(name)
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+                ui.horizontal(|ui| {
+                    if let Some(match_percent) = symbol_diff.match_percent {
+                        ui.label(
+                            RichText::new(format!("{:.0}%", match_percent.floor()))
+                                .font(appearance.code_font.clone())
+                                .color(match_color_for_symbol(match_percent, appearance)),
+                        );
+                    }
+                    if left_ctx.map_or(false, |m| m.has_symbol()) {
                         ui.separator();
                         if ui
-                            .add_enabled(
-                                state.source_path_available,
-                                egui::Button::new("🖹 Source file"),
+                            .button("Change base")
+                            .on_hover_text_at_pointer(
+                                "Choose a different symbol to use as the base",
                             )
-                            .on_hover_text_at_pointer("Open the source file in the default editor")
-                            .on_disabled_hover_text("Source file metadata missing")
                             .clicked()
                         {
-                            state.queue_open_source_path = true;
-                        }
-                    });
-
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        if let Some(match_percent) = result
-                            .second_obj
-                            .as_ref()
-                            .and_then(|(obj, diff)| {
-                                find_symbol(obj, selected_symbol).map(|sref| {
-                                    &diff.sections[sref.section_idx].symbols[sref.symbol_idx]
-                                })
-                            })
-                            .and_then(|symbol| symbol.match_percent)
-                        {
-                            ui.colored_label(
-                                match_color_for_symbol(match_percent, appearance),
-                                format!("{:.0}%", match_percent.floor()),
-                            );
-                        } else {
-                            ui.colored_label(appearance.replace_color, "Missing");
+                            if let Some(symbol_ref) = state.symbol_state.left_symbol.as_ref() {
+                                ret = Some(DiffViewAction::SelectingRight(symbol_ref.clone()));
+                            }
                         }
-                        ui.label("Diff base:");
-                    });
-                },
-            );
-        },
-    );
-    ui.separator();
+                    }
+                });
+            } else {
+                ui.label(
+                    RichText::new("Missing")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.replace_color),
+                );
+                ui.label(
+                    RichText::new("Choose base symbol")
+                        .font(appearance.code_font.clone())
+                        .color(appearance.highlight_color),
+                );
+            }
+        }
+    });
 
     // Table
-    ui.style_mut().interaction.selectable_labels = false;
-    let available_height = ui.available_height();
-    let table = TableBuilder::new(ui)
-        .striped(false)
-        .cell_layout(Layout::left_to_right(Align::Min))
-        .columns(Column::exact(column_width).clip(true), 2)
-        .resizable(false)
-        .auto_shrink([false, false])
-        .min_scrolled_height(available_height)
-        .sense(Sense::click());
-    asm_table_ui(
-        table,
-        result.first_obj.as_ref(),
-        result.second_obj.as_ref(),
-        selected_symbol,
-        appearance,
-        &mut state.function_state,
-    );
+    let id = Id::new(state.symbol_state.left_symbol.as_ref().map(|s| s.symbol_name.as_str()))
+        .with(state.symbol_state.right_symbol.as_ref().map(|s| s.symbol_name.as_str()));
+    if let Some(action) = ui
+        .push_id(id, |ui| {
+            asm_table_ui(
+                ui,
+                available_width,
+                left_ctx,
+                right_ctx,
+                appearance,
+                &state.function_state,
+                &state.symbol_state,
+            )
+        })
+        .inner
+    {
+        ret = Some(action);
+    }
+    ret
 }
diff --git a/objdiff-gui/src/views/mod.rs b/objdiff-gui/src/views/mod.rs
index 7b31c54..16e9380 100644
--- a/objdiff-gui/src/views/mod.rs
+++ b/objdiff-gui/src/views/mod.rs
@@ -1,6 +1,7 @@
 use egui::{text::LayoutJob, Color32, FontId, TextFormat};
 
 pub(crate) mod appearance;
+pub(crate) mod column_layout;
 pub(crate) mod config;
 pub(crate) mod data_diff;
 pub(crate) mod debug;
diff --git a/objdiff-gui/src/views/symbol_diff.rs b/objdiff-gui/src/views/symbol_diff.rs
index 8d9618c..cd17eb4 100644
--- a/objdiff-gui/src/views/symbol_diff.rs
+++ b/objdiff-gui/src/views/symbol_diff.rs
@@ -1,13 +1,12 @@
-use std::mem::take;
+use std::{collections::BTreeMap, mem::take};
 
 use egui::{
-    text::LayoutJob, Align, CollapsingHeader, Color32, Id, Layout, OpenUrl, ScrollArea,
-    SelectableLabel, TextEdit, Ui, Vec2, Widget,
+    text::LayoutJob, CollapsingHeader, Color32, Id, OpenUrl, ScrollArea, SelectableLabel, TextEdit,
+    Ui, Widget,
 };
-use egui_extras::{Size, StripBuilder};
 use objdiff_core::{
     arch::ObjArch,
-    diff::{ObjDiff, ObjSymbolDiff},
+    diff::{display::HighlightKind, ObjDiff, ObjSymbolDiff},
     obj::{ObjInfo, ObjSection, ObjSectionKind, ObjSymbol, ObjSymbolFlags, SymbolRef},
 };
 use regex::{Regex, RegexBuilder};
@@ -19,17 +18,28 @@ use crate::{
         objdiff::{BuildStatus, ObjDiffResult},
         Job, JobQueue, JobResult,
     },
-    views::{appearance::Appearance, function_diff::FunctionViewState, write_text},
+    views::{
+        appearance::Appearance,
+        column_layout::{render_header, render_strips},
+        function_diff::FunctionViewState,
+        write_text,
+    },
 };
 
+#[derive(Debug, Clone)]
 pub struct SymbolRefByName {
     pub symbol_name: String,
-    pub demangled_symbol_name: Option<String>,
-    pub section_name: String,
+    pub section_name: Option<String>,
+}
+
+impl SymbolRefByName {
+    pub fn new(symbol: &ObjSymbol, section: Option<&ObjSection>) -> Self {
+        Self { symbol_name: symbol.name.clone(), section_name: section.map(|s| s.name.clone()) }
+    }
 }
 
-#[allow(clippy::enum_variant_names)]
-#[derive(Default, Eq, PartialEq, Copy, Clone)]
+#[expect(clippy::enum_variant_names)]
+#[derive(Debug, Default, Eq, PartialEq, Copy, Clone, Hash)]
 pub enum View {
     #[default]
     SymbolDiff,
@@ -38,6 +48,71 @@ pub enum View {
     ExtabDiff,
 }
 
+#[derive(Debug, Clone)]
+pub enum DiffViewAction {
+    /// Queue a rebuild of the current object(s)
+    Build,
+    /// Navigate to a new diff view
+    Navigate(DiffViewNavigation),
+    /// Set the highlighted symbols in the symbols view
+    SetSymbolHighlight(Option<SymbolRef>, Option<SymbolRef>),
+    /// Set the symbols view search filter
+    SetSearch(String),
+    /// Submit the current function to decomp.me
+    CreateScratch(String),
+    /// Open the source path of the current object
+    OpenSourcePath,
+    /// Set the highlight for a diff column
+    SetDiffHighlight(usize, HighlightKind),
+    /// Clear the highlight for all diff columns
+    ClearDiffHighlight,
+    /// Start selecting a left symbol for mapping.
+    /// The symbol reference is the right symbol to map to.
+    SelectingLeft(SymbolRefByName),
+    /// Start selecting a right symbol for mapping.
+    /// The symbol reference is the left symbol to map to.
+    SelectingRight(SymbolRefByName),
+    /// Set a symbol mapping.
+    SetMapping(View, SymbolRefByName, SymbolRefByName),
+    /// Set the show_mapped_symbols flag
+    SetShowMappedSymbols(bool),
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct DiffViewNavigation {
+    pub view: Option<View>,
+    pub left_symbol: Option<SymbolRefByName>,
+    pub right_symbol: Option<SymbolRefByName>,
+}
+
+impl DiffViewNavigation {
+    pub fn symbol_diff() -> Self {
+        Self { view: Some(View::SymbolDiff), left_symbol: None, right_symbol: None }
+    }
+
+    pub fn with_symbols(
+        view: View,
+        other_ctx: Option<SymbolDiffContext<'_>>,
+        symbol: &ObjSymbol,
+        section: &ObjSection,
+        symbol_diff: &ObjSymbolDiff,
+        column: usize,
+    ) -> Self {
+        let symbol1 = Some(SymbolRefByName::new(symbol, Some(section)));
+        let symbol2 = symbol_diff.target_symbol.and_then(|symbol_ref| {
+            other_ctx.map(|ctx| {
+                let (section, symbol) = ctx.obj.section_symbol(symbol_ref);
+                SymbolRefByName::new(symbol, section)
+            })
+        });
+        match column {
+            0 => Self { view: Some(view), left_symbol: symbol1, right_symbol: symbol2 },
+            1 => Self { view: Some(view), left_symbol: symbol2, right_symbol: symbol1 },
+            _ => unreachable!("Invalid column index"),
+        }
+    }
+}
+
 #[derive(Default)]
 pub struct DiffViewState {
     pub build: Option<Box<ObjDiffResult>>,
@@ -47,22 +122,23 @@ pub struct DiffViewState {
     pub function_state: FunctionViewState,
     pub search: String,
     pub search_regex: Option<Regex>,
-    pub queue_build: bool,
     pub build_running: bool,
     pub scratch_available: bool,
-    pub queue_scratch: bool,
     pub scratch_running: bool,
     pub source_path_available: bool,
-    pub queue_open_source_path: bool,
+    pub post_build_nav: Option<DiffViewNavigation>,
+    pub object_name: String,
 }
 
 #[derive(Default)]
 pub struct SymbolViewState {
     pub highlighted_symbol: (Option<SymbolRef>, Option<SymbolRef>),
-    pub selected_symbol: Option<SymbolRefByName>,
+    pub left_symbol: Option<SymbolRefByName>,
+    pub right_symbol: Option<SymbolRefByName>,
     pub reverse_fn_order: bool,
     pub disable_reverse_fn_order: bool,
     pub show_hidden_symbols: bool,
+    pub show_mapped_symbols: bool,
 }
 
 impl DiffViewState {
@@ -70,6 +146,16 @@ impl DiffViewState {
         jobs.results.retain_mut(|result| match result {
             JobResult::ObjDiff(result) => {
                 self.build = take(result);
+
+                // TODO: where should this go?
+                if let Some(result) = self.post_build_nav.take() {
+                    if let Some(view) = result.view {
+                        self.current_view = view;
+                    }
+                    self.symbol_state.left_symbol = result.left_symbol;
+                    self.symbol_state.right_symbol = result.right_symbol;
+                }
+
                 false
             }
             JobResult::CreateScratch(result) => {
@@ -93,44 +179,103 @@ impl DiffViewState {
                 self.source_path_available = false;
             }
             self.scratch_available = CreateScratchConfig::is_available(&state.config);
+            self.object_name =
+                state.config.selected_obj.as_ref().map(|o| o.name.clone()).unwrap_or_default();
         }
     }
 
-    pub fn post_update(&mut self, ctx: &egui::Context, jobs: &mut JobQueue, state: &AppStateRef) {
+    pub fn post_update(
+        &mut self,
+        action: Option<DiffViewAction>,
+        ctx: &egui::Context,
+        jobs: &mut JobQueue,
+        state: &AppStateRef,
+    ) {
         if let Some(result) = take(&mut self.scratch) {
             ctx.output_mut(|o| o.open_url = Some(OpenUrl::new_tab(result.scratch_url)));
         }
 
-        if self.queue_build {
-            self.queue_build = false;
-            if let Ok(mut state) = state.write() {
-                state.queue_build = true;
+        let Some(action) = action else {
+            return;
+        };
+        match action {
+            DiffViewAction::Build => {
+                if let Ok(mut state) = state.write() {
+                    state.queue_build = true;
+                }
             }
-        }
-
-        if self.queue_scratch {
-            self.queue_scratch = false;
-            if let Some(function_name) =
-                self.symbol_state.selected_symbol.as_ref().map(|sym| sym.symbol_name.clone())
-            {
-                if let Ok(state) = state.read() {
-                    match CreateScratchConfig::from_config(&state.config, function_name) {
-                        Ok(config) => {
-                            jobs.push_once(Job::CreateScratch, || {
-                                start_create_scratch(ctx, config)
-                            });
+            DiffViewAction::Navigate(nav) => {
+                if self.post_build_nav.is_some() {
+                    // Ignore action if we're already navigating
+                    return;
+                }
+                self.symbol_state.highlighted_symbol = (None, None);
+                let Ok(mut state) = state.write() else {
+                    return;
+                };
+                if (nav.left_symbol.is_some() && nav.right_symbol.is_some())
+                    || (nav.left_symbol.is_none() && nav.right_symbol.is_none())
+                    || nav.view != Some(View::FunctionDiff)
+                {
+                    // Regular navigation
+                    if state.is_selecting_symbol() {
+                        // Cancel selection and reload
+                        state.clear_selection();
+                        self.post_build_nav = Some(nav);
+                    } else {
+                        // Navigate immediately
+                        if let Some(view) = nav.view {
+                            self.current_view = view;
+                        }
+                        self.symbol_state.left_symbol = nav.left_symbol;
+                        self.symbol_state.right_symbol = nav.right_symbol;
+                    }
+                } else {
+                    // Enter selection mode
+                    match (&nav.left_symbol, &nav.right_symbol) {
+                        (Some(left_ref), None) => {
+                            state.set_selecting_right(&left_ref.symbol_name);
                         }
-                        Err(err) => {
-                            log::error!("Failed to create scratch config: {err}");
+                        (None, Some(right_ref)) => {
+                            state.set_selecting_left(&right_ref.symbol_name);
                         }
+                        (Some(_), Some(_)) => unreachable!(),
+                        (None, None) => unreachable!(),
                     }
+                    self.post_build_nav = Some(nav);
                 }
             }
-        }
-
-        if self.queue_open_source_path {
-            self.queue_open_source_path = false;
-            if let Ok(state) = state.read() {
+            DiffViewAction::SetSymbolHighlight(left, right) => {
+                self.symbol_state.highlighted_symbol = (left, right);
+            }
+            DiffViewAction::SetSearch(search) => {
+                self.search_regex = if search.is_empty() {
+                    None
+                } else if let Ok(regex) = RegexBuilder::new(&search).case_insensitive(true).build()
+                {
+                    Some(regex)
+                } else {
+                    None
+                };
+                self.search = search;
+            }
+            DiffViewAction::CreateScratch(function_name) => {
+                let Ok(state) = state.read() else {
+                    return;
+                };
+                match CreateScratchConfig::from_config(&state.config, function_name) {
+                    Ok(config) => {
+                        jobs.push_once(Job::CreateScratch, || start_create_scratch(ctx, config));
+                    }
+                    Err(err) => {
+                        log::error!("Failed to create scratch config: {err}");
+                    }
+                }
+            }
+            DiffViewAction::OpenSourcePath => {
+                let Ok(state) = state.read() else {
+                    return;
+                };
                 if let (Some(project_dir), Some(source_path)) = (
                     &state.config.project_dir,
                     state.config.selected_obj.as_ref().and_then(|obj| obj.source_path.as_ref()),
@@ -142,6 +287,67 @@ impl DiffViewState {
                     });
                 }
             }
+            DiffViewAction::SetDiffHighlight(column, kind) => {
+                self.function_state.set_highlight(column, kind);
+            }
+            DiffViewAction::ClearDiffHighlight => {
+                self.function_state.clear_highlight();
+            }
+            DiffViewAction::SelectingLeft(right_ref) => {
+                if self.post_build_nav.is_some() {
+                    // Ignore action if we're already navigating
+                    return;
+                }
+                let Ok(mut state) = state.write() else {
+                    return;
+                };
+                state.set_selecting_left(&right_ref.symbol_name);
+                self.post_build_nav = Some(DiffViewNavigation {
+                    view: Some(View::FunctionDiff),
+                    left_symbol: None,
+                    right_symbol: Some(right_ref),
+                });
+            }
+            DiffViewAction::SelectingRight(left_ref) => {
+                if self.post_build_nav.is_some() {
+                    // Ignore action if we're already navigating
+                    return;
+                }
+                let Ok(mut state) = state.write() else {
+                    return;
+                };
+                state.set_selecting_right(&left_ref.symbol_name);
+                self.post_build_nav = Some(DiffViewNavigation {
+                    view: Some(View::FunctionDiff),
+                    left_symbol: Some(left_ref),
+                    right_symbol: None,
+                });
+            }
+            DiffViewAction::SetMapping(view, left_ref, right_ref) => {
+                if self.post_build_nav.is_some() {
+                    // Ignore action if we're already navigating
+                    return;
+                }
+                let Ok(mut state) = state.write() else {
+                    return;
+                };
+                state.set_symbol_mapping(
+                    left_ref.symbol_name.clone(),
+                    right_ref.symbol_name.clone(),
+                );
+                if view == View::SymbolDiff {
+                    self.post_build_nav = Some(DiffViewNavigation::symbol_diff());
+                } else {
+                    self.post_build_nav = Some(DiffViewNavigation {
+                        view: Some(view),
+                        left_symbol: Some(left_ref),
+                        right_symbol: Some(right_ref),
+                    });
+                }
+            }
+            DiffViewAction::SetShowMappedSymbols(value) => {
+                self.symbol_state.show_mapped_symbols = value;
+            }
         }
     }
 }
@@ -158,11 +364,13 @@ pub fn match_color_for_symbol(match_percent: f32, appearance: &Appearance) -> Co
 
 fn symbol_context_menu_ui(
     ui: &mut Ui,
-    state: &mut SymbolViewState,
-    arch: &dyn ObjArch,
+    ctx: SymbolDiffContext<'_>,
+    other_ctx: Option<SymbolDiffContext<'_>>,
     symbol: &ObjSymbol,
+    symbol_diff: &ObjSymbolDiff,
     section: Option<&ObjSection>,
-) -> Option<View> {
+    column: usize,
+) -> Option<DiffViewNavigation> {
     let mut ret = None;
     ui.scope(|ui| {
         ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
@@ -185,14 +393,35 @@ fn symbol_context_menu_ui(
             }
         }
         if let Some(section) = section {
-            let has_extab = arch.ppc().and_then(|ppc| ppc.extab_for_symbol(symbol)).is_some();
+            let has_extab =
+                ctx.obj.arch.ppc().and_then(|ppc| ppc.extab_for_symbol(symbol)).is_some();
             if has_extab && ui.button("Decode exception table").clicked() {
-                state.selected_symbol = Some(SymbolRefByName {
-                    symbol_name: symbol.name.clone(),
-                    demangled_symbol_name: symbol.demangled_name.clone(),
-                    section_name: section.name.clone(),
-                });
-                ret = Some(View::ExtabDiff);
+                ret = Some(DiffViewNavigation::with_symbols(
+                    View::ExtabDiff,
+                    other_ctx,
+                    symbol,
+                    section,
+                    symbol_diff,
+                    column,
+                ));
+                ui.close_menu();
+            }
+
+            if ui.button("Map symbol").clicked() {
+                let symbol_ref = SymbolRefByName::new(symbol, Some(section));
+                if column == 0 {
+                    ret = Some(DiffViewNavigation {
+                        view: Some(View::FunctionDiff),
+                        left_symbol: Some(symbol_ref),
+                        right_symbol: None,
+                    });
+                } else {
+                    ret = Some(DiffViewNavigation {
+                        view: Some(View::FunctionDiff),
+                        left_symbol: None,
+                        right_symbol: Some(symbol_ref),
+                    });
+                }
                 ui.close_menu();
             }
         }
@@ -232,27 +461,28 @@ fn symbol_hover_ui(ui: &mut Ui, arch: &dyn ObjArch, symbol: &ObjSymbol, appearan
 }
 
 #[must_use]
-#[allow(clippy::too_many_arguments)]
+#[expect(clippy::too_many_arguments)]
 fn symbol_ui(
     ui: &mut Ui,
-    arch: &dyn ObjArch,
+    ctx: SymbolDiffContext<'_>,
+    other_ctx: Option<SymbolDiffContext<'_>>,
     symbol: &ObjSymbol,
     symbol_diff: &ObjSymbolDiff,
     section: Option<&ObjSection>,
-    state: &mut SymbolViewState,
+    state: &SymbolViewState,
     appearance: &Appearance,
-    left: bool,
-) -> Option<View> {
+    column: usize,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
     if symbol.flags.0.contains(ObjSymbolFlags::Hidden) && !state.show_hidden_symbols {
-        return None;
+        return ret;
     }
-    let mut ret = None;
     let mut job = LayoutJob::default();
     let name: &str =
         if let Some(demangled) = &symbol.demangled_name { demangled } else { &symbol.name };
     let mut selected = false;
     if let Some(sym_ref) =
-        if left { state.highlighted_symbol.0 } else { state.highlighted_symbol.1 }
+        if column == 0 { state.highlighted_symbol.0 } else { state.highlighted_symbol.1 }
     {
         selected = symbol_diff.symbol_ref == sym_ref;
     }
@@ -292,90 +522,166 @@ fn symbol_ui(
         write_text(") ", appearance.text_color, &mut job, appearance.code_font.clone());
     }
     write_text(name, appearance.highlight_color, &mut job, appearance.code_font.clone());
-    let response = SelectableLabel::new(selected, job)
-        .ui(ui)
-        .on_hover_ui_at_pointer(|ui| symbol_hover_ui(ui, arch, symbol, appearance));
+    let response = SelectableLabel::new(selected, job).ui(ui).on_hover_ui_at_pointer(|ui| {
+        symbol_hover_ui(ui, ctx.obj.arch.as_ref(), symbol, appearance)
+    });
     response.context_menu(|ui| {
-        ret = ret.or(symbol_context_menu_ui(ui, state, arch, symbol, section));
+        if let Some(result) =
+            symbol_context_menu_ui(ui, ctx, other_ctx, symbol, symbol_diff, section, column)
+        {
+            ret = Some(DiffViewAction::Navigate(result));
+        }
     });
     if response.clicked() {
         if let Some(section) = section {
-            if section.kind == ObjSectionKind::Code {
-                state.selected_symbol = Some(SymbolRefByName {
-                    symbol_name: symbol.name.clone(),
-                    demangled_symbol_name: symbol.demangled_name.clone(),
-                    section_name: section.name.clone(),
-                });
-                ret = Some(View::FunctionDiff);
-            } else if section.kind == ObjSectionKind::Data {
-                state.selected_symbol = Some(SymbolRefByName {
-                    symbol_name: section.name.clone(),
-                    demangled_symbol_name: None,
-                    section_name: section.name.clone(),
-                });
-                ret = Some(View::DataDiff);
+            match section.kind {
+                ObjSectionKind::Code => {
+                    ret = Some(DiffViewAction::Navigate(DiffViewNavigation::with_symbols(
+                        View::FunctionDiff,
+                        other_ctx,
+                        symbol,
+                        section,
+                        symbol_diff,
+                        column,
+                    )));
+                }
+                ObjSectionKind::Data => {
+                    ret = Some(DiffViewAction::Navigate(DiffViewNavigation::with_symbols(
+                        View::DataDiff,
+                        other_ctx,
+                        symbol,
+                        section,
+                        symbol_diff,
+                        column,
+                    )));
+                }
+                ObjSectionKind::Bss => {}
             }
         }
     } else if response.hovered() {
-        state.highlighted_symbol = if let Some(diff_symbol) = symbol_diff.diff_symbol {
-            if left {
-                (Some(symbol_diff.symbol_ref), Some(diff_symbol))
+        ret = Some(if let Some(target_symbol) = symbol_diff.target_symbol {
+            if column == 0 {
+                DiffViewAction::SetSymbolHighlight(
+                    Some(symbol_diff.symbol_ref),
+                    Some(target_symbol),
+                )
             } else {
-                (Some(diff_symbol), Some(symbol_diff.symbol_ref))
+                DiffViewAction::SetSymbolHighlight(
+                    Some(target_symbol),
+                    Some(symbol_diff.symbol_ref),
+                )
             }
         } else {
-            (None, None)
-        };
+            DiffViewAction::SetSymbolHighlight(None, None)
+        });
     }
     ret
 }
 
-fn symbol_matches_search(symbol: &ObjSymbol, search_regex: Option<&Regex>) -> bool {
-    if let Some(search_regex) = search_regex {
-        search_regex.is_match(&symbol.name)
-            || symbol.demangled_name.as_ref().map(|s| search_regex.is_match(s)).unwrap_or(false)
-    } else {
-        true
+fn symbol_matches_filter(
+    symbol: &ObjSymbol,
+    diff: &ObjSymbolDiff,
+    filter: SymbolFilter<'_>,
+) -> bool {
+    match filter {
+        SymbolFilter::None => true,
+        SymbolFilter::Search(regex) => {
+            regex.is_match(&symbol.name)
+                || symbol.demangled_name.as_ref().map(|s| regex.is_match(s)).unwrap_or(false)
+        }
+        SymbolFilter::Mapping(symbol_ref) => diff.target_symbol == Some(symbol_ref),
     }
 }
 
+#[derive(Copy, Clone)]
+pub enum SymbolFilter<'a> {
+    None,
+    Search(&'a Regex),
+    Mapping(SymbolRef),
+}
+
 #[must_use]
-fn symbol_list_ui(
+pub fn symbol_list_ui(
     ui: &mut Ui,
-    obj: &(ObjInfo, ObjDiff),
-    state: &mut SymbolViewState,
-    search_regex: Option<&Regex>,
+    ctx: SymbolDiffContext<'_>,
+    other_ctx: Option<SymbolDiffContext<'_>>,
+    state: &SymbolViewState,
+    filter: SymbolFilter<'_>,
     appearance: &Appearance,
-    left: bool,
-) -> Option<View> {
+    column: usize,
+) -> Option<DiffViewAction> {
     let mut ret = None;
-    let arch = obj.0.arch.as_ref();
     ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
+        let mut mapping = BTreeMap::new();
+        if let SymbolFilter::Mapping(target_ref) = filter {
+            let mut show_mapped_symbols = state.show_mapped_symbols;
+            if ui.checkbox(&mut show_mapped_symbols, "Show mapped symbols").changed() {
+                ret = Some(DiffViewAction::SetShowMappedSymbols(show_mapped_symbols));
+            }
+            for mapping_diff in &ctx.diff.mapping_symbols {
+                if mapping_diff.target_symbol == Some(target_ref) {
+                    if !show_mapped_symbols {
+                        let symbol_diff = ctx.diff.symbol_diff(mapping_diff.symbol_ref);
+                        if symbol_diff.target_symbol.is_some() {
+                            continue;
+                        }
+                    }
+                    mapping.insert(mapping_diff.symbol_ref, mapping_diff);
+                }
+            }
+        } else {
+            for (symbol, diff) in ctx.obj.common.iter().zip(&ctx.diff.common) {
+                if !symbol_matches_filter(symbol, diff, filter) {
+                    continue;
+                }
+                mapping.insert(diff.symbol_ref, diff);
+            }
+            for (section, section_diff) in ctx.obj.sections.iter().zip(&ctx.diff.sections) {
+                for (symbol, symbol_diff) in section.symbols.iter().zip(&section_diff.symbols) {
+                    if !symbol_matches_filter(symbol, symbol_diff, filter) {
+                        continue;
+                    }
+                    mapping.insert(symbol_diff.symbol_ref, symbol_diff);
+                }
+            }
+        }
+
         ui.scope(|ui| {
             ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
             ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
 
-            if !obj.0.common.is_empty() {
+            // Skip sections with all symbols filtered out
+            if mapping.keys().any(|symbol_ref| symbol_ref.section_idx == usize::MAX) {
                 CollapsingHeader::new(".comm").default_open(true).show(ui, |ui| {
-                    for (symbol, symbol_diff) in obj.0.common.iter().zip(&obj.1.common) {
-                        if !symbol_matches_search(symbol, search_regex) {
-                            continue;
-                        }
-                        ret = ret.or(symbol_ui(
+                    for (symbol_ref, symbol_diff) in mapping
+                        .iter()
+                        .filter(|(symbol_ref, _)| symbol_ref.section_idx == usize::MAX)
+                    {
+                        let symbol = ctx.obj.section_symbol(*symbol_ref).1;
+                        if let Some(result) = symbol_ui(
                             ui,
-                            arch,
+                            ctx,
+                            other_ctx,
                             symbol,
                             symbol_diff,
                             None,
                             state,
                             appearance,
-                            left,
-                        ));
+                            column,
+                        ) {
+                            ret = Some(result);
+                        }
                     }
                 });
             }
 
-            for (section, section_diff) in obj.0.sections.iter().zip(&obj.1.sections) {
+            for ((section_index, section), section_diff) in
+                ctx.obj.sections.iter().enumerate().zip(&ctx.diff.sections)
+            {
+                // Skip sections with all symbols filtered out
+                if !mapping.keys().any(|symbol_ref| symbol_ref.section_idx == section_index) {
+                    continue;
+                }
                 let mut header = LayoutJob::simple_singleline(
                     format!("{} ({:x})", section.name, section.size),
                     appearance.code_font.clone(),
@@ -406,40 +712,45 @@ fn symbol_list_ui(
                     .default_open(true)
                     .show(ui, |ui| {
                         if section.kind == ObjSectionKind::Code && state.reverse_fn_order {
-                            for (symbol, symbol_diff) in
-                                section.symbols.iter().zip(&section_diff.symbols).rev()
+                            for (symbol, symbol_diff) in mapping
+                                .iter()
+                                .filter(|(symbol_ref, _)| symbol_ref.section_idx == section_index)
+                                .rev()
                             {
-                                if !symbol_matches_search(symbol, search_regex) {
-                                    continue;
-                                }
-                                ret = ret.or(symbol_ui(
+                                let symbol = ctx.obj.section_symbol(*symbol).1;
+                                if let Some(result) = symbol_ui(
                                     ui,
-                                    arch,
+                                    ctx,
+                                    other_ctx,
                                     symbol,
                                     symbol_diff,
                                     Some(section),
                                     state,
                                     appearance,
-                                    left,
-                                ));
+                                    column,
+                                ) {
+                                    ret = Some(result);
+                                }
                             }
                         } else {
-                            for (symbol, symbol_diff) in
-                                section.symbols.iter().zip(&section_diff.symbols)
+                            for (symbol, symbol_diff) in mapping
+                                .iter()
+                                .filter(|(symbol_ref, _)| symbol_ref.section_idx == section_index)
                             {
-                                if !symbol_matches_search(symbol, search_regex) {
-                                    continue;
-                                }
-                                ret = ret.or(symbol_ui(
+                                let symbol = ctx.obj.section_symbol(*symbol).1;
+                                if let Some(result) = symbol_ui(
                                     ui,
-                                    arch,
+                                    ctx,
+                                    other_ctx,
                                     symbol,
                                     symbol_diff,
                                     Some(section),
                                     state,
                                     appearance,
-                                    left,
-                                ));
+                                    column,
+                                ) {
+                                    ret = Some(result);
+                                }
                             }
                         }
                     });
@@ -487,155 +798,139 @@ fn missing_obj_ui(ui: &mut Ui, appearance: &Appearance) {
     });
 }
 
-pub fn symbol_diff_ui(ui: &mut Ui, state: &mut DiffViewState, appearance: &Appearance) {
-    let DiffViewState { build, current_view, symbol_state, search, search_regex, .. } = state;
-    let Some(result) = build else {
-        return;
+#[derive(Copy, Clone)]
+pub struct SymbolDiffContext<'a> {
+    pub obj: &'a ObjInfo,
+    pub diff: &'a ObjDiff,
+}
+
+#[must_use]
+pub fn symbol_diff_ui(
+    ui: &mut Ui,
+    state: &mut DiffViewState,
+    appearance: &Appearance,
+) -> Option<DiffViewAction> {
+    let mut ret = None;
+    let Some(result) = &state.build else {
+        return ret;
     };
 
     // Header
     let available_width = ui.available_width();
-    let column_width = available_width / 2.0;
-    ui.allocate_ui_with_layout(
-        Vec2 { x: available_width, y: 100.0 },
-        Layout::left_to_right(Align::Min),
-        |ui| {
-            ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
-
+    render_header(ui, available_width, 2, |ui, column| {
+        if column == 0 {
             // Left column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-
-                        ui.label("Build target:");
-                        if result.first_status.success {
-                            if result.first_obj.is_none() {
-                                ui.colored_label(appearance.replace_color, "Missing");
-                            } else {
-                                ui.label("OK");
-                            }
-                        } else {
-                            ui.colored_label(appearance.delete_color, "Fail");
-                        }
-                    });
-
-                    if TextEdit::singleline(search).hint_text("Filter symbols").ui(ui).changed() {
-                        if search.is_empty() {
-                            *search_regex = None;
-                        } else if let Ok(regex) =
-                            RegexBuilder::new(search).case_insensitive(true).build()
-                        {
-                            *search_regex = Some(regex);
-                        } else {
-                            *search_regex = None;
-                        }
+            ui.scope(|ui| {
+                ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
+
+                ui.label("Target object");
+                if result.first_status.success {
+                    if result.first_obj.is_none() {
+                        ui.colored_label(appearance.replace_color, "Missing");
+                    } else {
+                        ui.colored_label(appearance.highlight_color, state.object_name.clone());
                     }
-                },
-            );
+                } else {
+                    ui.colored_label(appearance.delete_color, "Fail");
+                }
+            });
 
+            let mut search = state.search.clone();
+            if TextEdit::singleline(&mut search).hint_text("Filter symbols").ui(ui).changed() {
+                ret = Some(DiffViewAction::SetSearch(search));
+            }
+        } else if column == 1 {
             // Right column
-            ui.allocate_ui_with_layout(
-                Vec2 { x: column_width, y: 100.0 },
-                Layout::top_down(Align::Min),
-                |ui| {
-                    ui.set_width(column_width);
-
-                    ui.horizontal(|ui| {
-                        ui.scope(|ui| {
-                            ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                            ui.label("Build base:");
-                        });
-                        ui.separator();
-                        if ui
-                            .add_enabled(
-                                state.source_path_available,
-                                egui::Button::new("🖹 Source file"),
-                            )
-                            .on_hover_text_at_pointer("Open the source file in the default editor")
-                            .on_disabled_hover_text("Source file metadata missing")
-                            .clicked()
-                        {
-                            state.queue_open_source_path = true;
-                        }
-                    });
-
-                    ui.scope(|ui| {
-                        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
-                        if result.second_status.success {
-                            if result.second_obj.is_none() {
-                                ui.colored_label(appearance.replace_color, "Missing");
-                            } else {
-                                ui.label("OK");
-                            }
-                        } else {
-                            ui.colored_label(appearance.delete_color, "Fail");
-                        }
-                    });
+            ui.horizontal(|ui| {
+                ui.scope(|ui| {
+                    ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
+                    ui.label("Base object");
+                });
+                ui.separator();
+                if ui
+                    .add_enabled(state.source_path_available, egui::Button::new("🖹 Source file"))
+                    .on_hover_text_at_pointer("Open the source file in the default editor")
+                    .on_disabled_hover_text("Source file metadata missing")
+                    .clicked()
+                {
+                    ret = Some(DiffViewAction::OpenSourcePath);
+                }
+            });
 
-                    if ui.add_enabled(!state.build_running, egui::Button::new("Build")).clicked() {
-                        state.queue_build = true;
+            ui.scope(|ui| {
+                ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
+                if result.second_status.success {
+                    if result.second_obj.is_none() {
+                        ui.colored_label(appearance.replace_color, "Missing");
+                    } else {
+                        ui.colored_label(appearance.highlight_color, "OK");
                     }
-                },
-            );
-        },
-    );
-    ui.separator();
-
-    // Table
-    let mut ret = None;
-    StripBuilder::new(ui).size(Size::remainder()).vertical(|mut strip| {
-        strip.strip(|builder| {
-            builder.sizes(Size::remainder(), 2).horizontal(|mut strip| {
-                strip.cell(|ui| {
-                    ui.push_id("left", |ui| {
-                        if result.first_status.success {
-                            if let Some(obj) = &result.first_obj {
-                                ret = ret.or(symbol_list_ui(
-                                    ui,
-                                    obj,
-                                    symbol_state,
-                                    search_regex.as_ref(),
-                                    appearance,
-                                    true,
-                                ));
-                            } else {
-                                missing_obj_ui(ui, appearance);
-                            }
-                        } else {
-                            build_log_ui(ui, &result.first_status, appearance);
-                        }
-                    });
-                });
-                strip.cell(|ui| {
-                    ui.push_id("right", |ui| {
-                        if result.second_status.success {
-                            if let Some(obj) = &result.second_obj {
-                                ret = ret.or(symbol_list_ui(
-                                    ui,
-                                    obj,
-                                    symbol_state,
-                                    search_regex.as_ref(),
-                                    appearance,
-                                    false,
-                                ));
-                            } else {
-                                missing_obj_ui(ui, appearance);
-                            }
-                        } else {
-                            build_log_ui(ui, &result.second_status, appearance);
-                        }
-                    });
-                });
+                } else {
+                    ui.colored_label(appearance.delete_color, "Fail");
+                }
             });
-        });
+
+            if ui.add_enabled(!state.build_running, egui::Button::new("Build")).clicked() {
+                ret = Some(DiffViewAction::Build);
+            }
+        }
     });
 
-    if let Some(view) = ret {
-        *current_view = view;
-    }
+    // Table
+    let filter = match &state.search_regex {
+        Some(regex) => SymbolFilter::Search(regex),
+        _ => SymbolFilter::None,
+    };
+    render_strips(ui, available_width, 2, |ui, column| {
+        if column == 0 {
+            // Left column
+            if result.first_status.success {
+                if let Some((obj, diff)) = &result.first_obj {
+                    if let Some(result) = symbol_list_ui(
+                        ui,
+                        SymbolDiffContext { obj, diff },
+                        result
+                            .second_obj
+                            .as_ref()
+                            .map(|(obj, diff)| SymbolDiffContext { obj, diff }),
+                        &state.symbol_state,
+                        filter,
+                        appearance,
+                        column,
+                    ) {
+                        ret = Some(result);
+                    }
+                } else {
+                    missing_obj_ui(ui, appearance);
+                }
+            } else {
+                build_log_ui(ui, &result.first_status, appearance);
+            }
+        } else if column == 1 {
+            // Right column
+            if result.second_status.success {
+                if let Some((obj, diff)) = &result.second_obj {
+                    if let Some(result) = symbol_list_ui(
+                        ui,
+                        SymbolDiffContext { obj, diff },
+                        result
+                            .first_obj
+                            .as_ref()
+                            .map(|(obj, diff)| SymbolDiffContext { obj, diff }),
+                        &state.symbol_state,
+                        filter,
+                        appearance,
+                        column,
+                    ) {
+                        ret = Some(result);
+                    }
+                } else {
+                    missing_obj_ui(ui, appearance);
+                }
+            } else {
+                build_log_ui(ui, &result.second_status, appearance);
+            }
+        }
+    });
+    ret
 }


Test patch: