diff --git a/agb-debug/Cargo.toml b/agb-debug/Cargo.toml
index 4c3f41f9..12977469 100644
--- a/agb-debug/Cargo.toml
+++ b/agb-debug/Cargo.toml
@@ -8,7 +8,7 @@ description = "CLI utility to convert agb stack trace dumps into human readable
 repository = "https://github.com/agbrs/agb"
 
 [dependencies]
-anyhow = "1"
+thiserror = "1"
 clap = { version = "4", features = ["derive"] }
 addr2line = { version = "0.22", default-features = false, features = [
     "rustc-demangle",
diff --git a/agb-debug/src/gwilym_encoding.rs b/agb-debug/src/gwilym_encoding.rs
index 1ce957f5..f192c28a 100644
--- a/agb-debug/src/gwilym_encoding.rs
+++ b/agb-debug/src/gwilym_encoding.rs
@@ -1,8 +1,20 @@
 use std::{slice::ChunksExact, sync::OnceLock};
 
+use thiserror::Error;
+
 const ALPHABET: &[u8] = b"0123456789=ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
 
-pub fn gwilym_decode(input: &str) -> anyhow::Result<GwilymDecodeIter<'_>> {
+#[derive(Debug, Error)]
+pub enum GwilymDecodeError {
+    #[error("Does not contain version")]
+    NoVersion,
+    #[error("Only version 1 is supported")]
+    WrongVersion,
+    #[error("Input must be a multiple of 3 but have {0}")]
+    LengthWrong(usize),
+}
+
+pub fn gwilym_decode(input: &str) -> Result<GwilymDecodeIter<'_>, GwilymDecodeError> {
     GwilymDecodeIter::new(input)
 }
 
@@ -11,21 +23,21 @@ pub struct GwilymDecodeIter<'a> {
 }
 
 impl<'a> GwilymDecodeIter<'a> {
-    fn new(input: &'a str) -> anyhow::Result<Self> {
+    fn new(input: &'a str) -> Result<Self, GwilymDecodeError> {
         let input = input
             .strip_prefix("https://agbrs.dev/crash#")
             .unwrap_or(input);
 
         let Some((input, version)) = input.rsplit_once('v') else {
-            anyhow::bail!("Does not contain version");
+            return Err(GwilymDecodeError::NoVersion);
         };
 
         if version != "1" {
-            anyhow::bail!("Only version 1 is supported");
+            return Err(GwilymDecodeError::WrongVersion);
         }
 
         if input.len() % 3 != 0 {
-            anyhow::bail!("Input string must have length a multiple of 3");
+            return Err(GwilymDecodeError::LengthWrong(input.len()));
         }
 
         Ok(Self {
@@ -75,11 +87,11 @@ fn get_value_for_char(input: u8) -> u32 {
 
 #[cfg(test)]
 mod test {
-    use super::{gwilym_decode, ALPHABET};
+    use super::*;
     use std::fmt::Write;
 
     #[test]
-    fn should_correctly_decode_16s() -> anyhow::Result<()> {
+    fn should_correctly_decode_16s() -> Result<(), GwilymDecodeError> {
         assert_eq!(
             &gwilym_decode("2QI65Q69306Kv1")?.collect::<Vec<_>>(),
             &[0x0800_16d3, 0x0800_315b, 0x0800_3243, 0x0800_0195]
@@ -112,7 +124,7 @@ mod test {
     }
 
     #[test]
-    fn should_correctly_decode_16s_and_32s() -> anyhow::Result<()> {
+    fn should_correctly_decode_16s_and_32s() -> Result<(), Box<dyn std::error::Error>> {
         let trace: &[u32] = &[
             0x0300_2990,
             0x0800_3289,
@@ -143,7 +155,7 @@ mod test {
     }
 
     #[test]
-    fn should_strip_the_agbrsdev_prefix() -> anyhow::Result<()> {
+    fn should_strip_the_agbrsdev_prefix() -> Result<(), Box<dyn std::error::Error>> {
         assert_eq!(
             &gwilym_decode("https://agbrs.dev/crash#2QI65Q69306Kv1")?.collect::<Vec<_>>(),
             &[0x0800_16d3, 0x0800_315b, 0x0800_3243, 0x0800_0195]
diff --git a/agb-debug/src/lib.rs b/agb-debug/src/lib.rs
new file mode 100644
index 00000000..e353a0ca
--- /dev/null
+++ b/agb-debug/src/lib.rs
@@ -0,0 +1,93 @@
+mod gwilym_encoding;
+mod load_dwarf;
+
+use addr2line::gimli::{self, EndianReader};
+pub use gwilym_encoding::{gwilym_decode, GwilymDecodeError};
+pub use load_dwarf::{load_dwarf, GimliDwarf, LoadDwarfError};
+use thiserror::Error;
+
+pub use addr2line;
+
+pub struct AddressInfo {
+    pub location: Location,
+    pub is_interesting: bool,
+    pub is_inline: bool,
+    pub function: String,
+}
+
+#[derive(Debug, Error)]
+pub enum AddressInfoError {
+    #[error(transparent)]
+    Gimli(#[from] gimli::Error),
+}
+
+pub struct Location {
+    pub filename: String,
+    pub line: u32,
+    pub col: u32,
+}
+
+pub type Addr2LineContext = addr2line::Context<gimli::EndianRcSlice<gimli::RunTimeEndian>>;
+
+impl Default for Location {
+    fn default() -> Self {
+        Self {
+            filename: "??".to_string(),
+            line: 0,
+            col: 0,
+        }
+    }
+}
+
+pub fn address_info(
+    ctx: &Addr2LineContext,
+    address: u64,
+) -> Result<Vec<AddressInfo>, AddressInfoError> {
+    let mut frames = ctx.find_frames(address).skip_all_loads()?;
+
+    let mut is_first = true;
+
+    let mut infos = Vec::new();
+
+    while let Some(frame) = frames.next()? {
+        let function_name = if let Some(ref func) = frame.function {
+            func.demangle()?.into_owned()
+        } else {
+            "unknown function".to_string()
+        };
+
+        let location = frame
+            .location
+            .as_ref()
+            .map(|location| Location {
+                filename: location.file.unwrap_or("??").to_owned(),
+                line: location.line.unwrap_or(0),
+                col: location.column.unwrap_or(0),
+            })
+            .unwrap_or_default();
+
+        let is_interesting = is_interesting_function(&function_name, &location.filename);
+
+        infos.push(AddressInfo {
+            location,
+            is_interesting,
+            is_inline: is_first,
+            function: function_name,
+        });
+        is_first = false;
+    }
+
+    Ok(infos)
+}
+
+fn is_interesting_function(function_name: &str, path: &str) -> bool {
+    if function_name == "rust_begin_unwind" {
+        return false; // this is the unwind exception call
+    }
+
+    if path.ends_with("panicking.rs") {
+        return false; // probably part of rust's internal panic mechanisms
+    }
+
+    true
+}
diff --git a/agb-debug/src/load_dwarf.rs b/agb-debug/src/load_dwarf.rs
index 394e5e57..d4b13ab1 100644
--- a/agb-debug/src/load_dwarf.rs
+++ b/agb-debug/src/load_dwarf.rs
@@ -4,38 +4,52 @@ use addr2line::{
     gimli,
     object::{self, Object},
 };
-use anyhow::bail;
+use thiserror::Error;
 
-pub fn load_dwarf(
-    file_content: &[u8],
-) -> anyhow::Result<gimli::Dwarf<gimli::EndianRcSlice<gimli::RunTimeEndian>>> {
+#[derive(Debug, Error)]
+pub enum LoadDwarfError {
+    #[error("Gba file is empty")]
+    GbaFileEmpty,
+    #[error("Failed to load debug information from ROM file, it might not have been included?")]
+    NoDebugInformation,
+    #[error("Failed to load debug information: {0}")]
+    DeserializationError(#[from] rmp_serde::decode::Error),
+    #[error(transparent)]
+    GimliError(#[from] gimli::Error),
+}
+
+pub type GimliDwarf = gimli::Dwarf<gimli::EndianRcSlice<gimli::RunTimeEndian>>;
+
+pub fn load_dwarf(file_content: &[u8]) -> Result<GimliDwarf, LoadDwarfError> {
     if let Ok(object) = object::File::parse(file_content) {
-        return load_from_object(&object);
+        return Ok(load_from_object(&object)?);
     }
 
     // the file might have been padded, so ensure we skip any padding before continuing
     let last_non_zero_byte = file_content
         .iter()
         .rposition(|&b| b != 0)
-        .ok_or_else(|| anyhow::anyhow!("Gba file is empty"))?;
+        .ok_or_else(|| LoadDwarfError::GbaFileEmpty)?;
 
     let file_content = &file_content[..last_non_zero_byte + 1];
 
     let last_8_bytes = &file_content[file_content.len() - 8..];
-    let len = u32::from_le_bytes(last_8_bytes[0..4].try_into()?) as usize;
+    let len = u32::from_le_bytes(
+        last_8_bytes[0..4]
+            .try_into()
+            .or(Err(LoadDwarfError::NoDebugInformation))?,
+    ) as usize;
     let version = &last_8_bytes[4..];
 
     if version != b"agb1" {
-        bail!("Failed to load debug information from ROM file, it might not have been included?");
+        return Err(LoadDwarfError::NoDebugInformation);
     }
 
     let compressed_debug_data = &file_content[file_content.len() - len - 8..file_content.len() - 8];
 
     let decompressing_reader =
         lz4_flex::frame::FrameDecoder::new(Cursor::new(compressed_debug_data));
-    let debug_info: HashMap<String, Vec<u8>> =
-        rmp_serde::decode::from_read(decompressing_reader)
-            .map_err(|e| anyhow::anyhow!("Failed to load debug information: {e}"))?;
+    let debug_info: HashMap<String, Vec<u8>> = rmp_serde::decode::from_read(decompressing_reader)?;
 
     let dwarf = gimli::Dwarf::load(|id| {
         let data = debug_info
@@ -54,7 +68,7 @@ pub fn load_dwarf(
 
 fn load_from_object<'file>(
     object: &object::File<'file, &'file [u8]>,
-) -> anyhow::Result<gimli::Dwarf<gimli::EndianRcSlice<gimli::RunTimeEndian>>> {
+) -> Result<GimliDwarf, gimli::Error> {
     let endian = if object.is_little_endian() {
         gimli::RunTimeEndian::Little
     } else {
diff --git a/agb-debug/src/main.rs b/agb-debug/src/main.rs
index efb5bd62..00611148 100644
--- a/agb-debug/src/main.rs
+++ b/agb-debug/src/main.rs
@@ -1,5 +1,6 @@
 use std::{
     borrow::Cow,
+    error::Error,
     fs::{self, File},
     io::Read,
     path::PathBuf,
@@ -7,12 +8,9 @@ use std::{
 };
 
 use addr2line::gimli;
+use agb_debug::Location;
 use clap::Parser;
 use colored::Colorize;
-use load_dwarf::load_dwarf;
-
-mod gwilym_encoding;
-mod load_dwarf;
 
 #[derive(Parser, Debug)]
 #[command(version, about, long_about = None)]
@@ -24,23 +22,7 @@ struct Args {
     dump: String,
 }
 
-struct Location {
-    filename: String,
-    line: u32,
-    col: u32,
-}
-
-impl Default for Location {
-    fn default() -> Self {
-        Self {
-            filename: "??".to_string(),
-            line: 0,
-            col: 0,
-        }
-    }
-}
-
-fn main() -> anyhow::Result<()> {
+fn main() -> Result<(), Box<dyn Error>> {
     let cli = Args::parse();
 
     let modification_time = fs::metadata(&cli.elf_path)?
@@ -48,11 +30,11 @@ fn main() -> anyhow::Result<()> {
         .unwrap_or(SystemTime::UNIX_EPOCH);
 
     let file = fs::read(&cli.elf_path)?;
-    let dwarf = load_dwarf(&file)?;
+    let dwarf = agb_debug::load_dwarf(&file)?;
 
     let ctx = addr2line::Context::from_dwarf(dwarf)?;
 
-    for (i, address) in gwilym_encoding::gwilym_decode(&cli.dump)?.enumerate() {
+    for (i, address) in agb_debug::gwilym_decode(&cli.dump)?.enumerate() {
         print_address(&ctx, i, address.into(), modification_time)?;
     }
 
@@ -64,7 +46,7 @@ fn print_address(
     index: usize,
     address: u64,
     elf_modification_time: SystemTime,
-) -> anyhow::Result<()> {
+) -> Result<(), Box<dyn Error>> {
     let mut frames = ctx.find_frames(address).skip_all_loads()?;
 
     let mut is_first = true;
@@ -119,7 +101,7 @@ fn print_line_of_code(
     frame: &addr2line::Frame<'_, impl gimli::Reader>,
     location: Location,
     elf_modification_time: SystemTime,
-) -> anyhow::Result<()> {
+) -> Result<(), Box<dyn Error>> {
     let Some(filename) = frame.location.as_ref().and_then(|location| location.file) else {
         return Ok(());
     };