tp: bundle source files and disassembly alongside symbols

`trace_processor bundle` now also writes sources.pb and disassembly.pb
members so the UI can show annotated source and assembly without the
binaries or source tree at hand.

Source files are the ones referenced by symbolized frames, both from
symbols already in the trace and those just produced. Only absolute
paths are tried, files over 1 MB are skipped and at most 16 MB is
bundled; --source-prefix-map FROM=TO reads files under a different root
while keeping the debug info path as the key, and --no-sources skips
the step.

Disassembly covers the functions containing sampled addresses of every
mapping the local symbolizer resolved against a binary on disk. The
symbolizer now reports that binary and the mapping-to-link-time address
correction; the bundler finds the containing functions with llvm-nm and
disassembles each with llvm-objdump, keeping bytes, text, statically
known branch targets and source lines. When the symbols came from a
split debug file (a .dSYM bundle or an ELF holding only debug info) the
code is taken from the mapping's own path, verified to have the same
build id, with objdump pointed at the debug file for lines. The last
symbol of a section extends to the section's end. --no-disassembly
skips the step. Both steps report what they bundled and, with
--verbose, what they could not.
diff --git a/Android.bp b/Android.bp
index 16e7649..5d79ad0 100644
--- a/Android.bp
+++ b/Android.bp
@@ -21981,10 +21981,21 @@
 filegroup {
     name: "perfetto_src_trace_processor_util_trace_enrichment_trace_enrichment",
     srcs: [
+        "src/trace_processor/util/trace_enrichment/disassembly.cc",
+        "src/trace_processor/util/trace_enrichment/source_files.cc",
         "src/trace_processor/util/trace_enrichment/trace_enrichment.cc",
     ],
 }
 
+// GN: //src/trace_processor/util/trace_enrichment:unittests
+filegroup {
+    name: "perfetto_src_trace_processor_util_trace_enrichment_unittests",
+    srcs: [
+        "src/trace_processor/util/trace_enrichment/disassembly_unittest.cc",
+        "src/trace_processor/util/trace_enrichment/source_files_unittest.cc",
+    ],
+}
+
 // GN: //src/trace_processor/util:trace_type
 filegroup {
     name: "perfetto_src_trace_processor_util_trace_type",
@@ -24336,6 +24347,7 @@
         ":perfetto_src_trace_processor_util_tar_writer",
         ":perfetto_src_trace_processor_util_trace_blob_view_reader",
         ":perfetto_src_trace_processor_util_trace_enrichment_trace_enrichment",
+        ":perfetto_src_trace_processor_util_trace_enrichment_unittests",
         ":perfetto_src_trace_processor_util_trace_type",
         ":perfetto_src_trace_processor_util_unittests",
         ":perfetto_src_trace_processor_util_zip_reader",
diff --git a/BUILD b/BUILD
index 308d466..58678b9 100644
--- a/BUILD
+++ b/BUILD
@@ -5936,6 +5936,10 @@
 perfetto_filegroup(
     name = "src_trace_processor_util_trace_enrichment_trace_enrichment",
     srcs = [
+        "src/trace_processor/util/trace_enrichment/disassembly.cc",
+        "src/trace_processor/util/trace_enrichment/disassembly.h",
+        "src/trace_processor/util/trace_enrichment/source_files.cc",
+        "src/trace_processor/util/trace_enrichment/source_files.h",
         "src/trace_processor/util/trace_enrichment/trace_enrichment.cc",
         "src/trace_processor/util/trace_enrichment/trace_enrichment.h",
     ],
diff --git a/docs/learning-more/symbolization.md b/docs/learning-more/symbolization.md
index c4fca19..51d6ed9 100644
--- a/docs/learning-more/symbolization.md
+++ b/docs/learning-more/symbolization.md
@@ -60,9 +60,29 @@
 deobfuscated names already applied.
 
 NOTE: As an implementation detail, the enriched trace is currently packaged as a
-TAR archive containing the original trace, native symbol packets, and
-Java/Kotlin deobfuscation packets. The UI and `trace_processor_shell` read this
-format transparently, so you normally don't need to unpack it yourself.
+TAR archive containing the original trace, native symbol packets, Java/Kotlin
+deobfuscation packets and the source files referenced by symbolized frames. The
+UI and `trace_processor_shell` read this format transparently, so you normally
+don't need to unpack it yourself.
+
+The source files are read from the absolute paths recorded in the debug info.
+When the source tree lives somewhere else on the bundling machine, map the
+build directory onto it with `--source-prefix-map FROM=TO` (repeatable):
+
+```bash
+trace_processor bundle --source-prefix-map /build/src=$HOME/src trace out.tar
+```
+
+Files over 1 MB are skipped and at most 16 MB of source is bundled. Pass
+`--no-sources` to leave source files out entirely, for example when sharing a
+bundle outside the team.
+
+The functions containing sampled addresses are also disassembled with
+`llvm-objdump` and bundled, so the UI can show per-instruction sample counts.
+This needs `llvm-nm` and `llvm-objdump` on `$PATH` and the unstripped binaries
+in `--symbol-paths`: files holding only debug info (for example the output of
+`objcopy --only-keep-debug`) carry no machine code. Pass `--no-disassembly` to
+skip this.
 
 **Requirements:**
 
diff --git a/src/trace_processor/BUILD.gn b/src/trace_processor/BUILD.gn
index a040e31..2931a72 100644
--- a/src/trace_processor/BUILD.gn
+++ b/src/trace_processor/BUILD.gn
@@ -465,6 +465,7 @@
     "util:unittests",
     "util/deobfuscation:unittests",
     "util/symbolizer:unittests",
+    "util/trace_enrichment:unittests",
   ]
   if (enable_perfetto_trace_processor_sqlite) {
     deps += [
diff --git a/src/trace_processor/shell/bundle_subcommand.cc b/src/trace_processor/shell/bundle_subcommand.cc
index 88fc42c..30fe1d2 100644
--- a/src/trace_processor/shell/bundle_subcommand.cc
+++ b/src/trace_processor/shell/bundle_subcommand.cc
@@ -84,9 +84,9 @@
 const char* BundleSubcommand::detailed_help() const {
   return R"(Create a self-contained bundle from a trace.
 
-Outputs a TAR containing the trace plus the symbols and deobfuscation
-mappings needed to make it self-contained. Both <input> and <output> must be
-real file paths (stdin/stdout are not supported).)";
+Outputs a TAR containing the trace plus the symbols, deobfuscation mappings
+and source files needed to make it self-contained. Both <input> and <output>
+must be real file paths (stdin/stdout are not supported).)";
 }
 
 std::vector<FlagSpec> BundleSubcommand::GetFlags() {
@@ -104,6 +104,18 @@
       BoolFlag("no-auto-proguard-maps", '\0',
                "Disable automatic ProGuard/R8 mapping discovery.",
                &no_auto_proguard_maps_),
+      BoolFlag("no-sources", '\0',
+               "Do not bundle the source files referenced by symbolized "
+               "frames.",
+               &no_source_files_),
+      FlagSpec{
+          "source-prefix-map", '\0', true, "FROM=TO",
+          "Read source files whose path in the debug info starts with FROM "
+          "from the same path under TO instead (may be repeated).",
+          [this](const char* v) { source_prefix_maps_.emplace_back(v); }},
+      BoolFlag("no-disassembly", '\0',
+               "Do not bundle the disassembly of sampled functions.",
+               &no_disassembly_),
       BoolFlag("verbose", '\0', "Print more detailed output.", &verbose_),
   };
 }
@@ -198,6 +210,18 @@
   }
   context.no_auto_symbol_paths = no_auto_symbol_paths_;
   context.no_auto_proguard_maps = no_auto_proguard_maps_;
+  context.no_source_files = no_source_files_;
+  for (const std::string& map : source_prefix_maps_) {
+    size_t eq = map.find('=');
+    if (eq == std::string::npos) {
+      return base::ErrStatus(
+          "bundle: --source-prefix-map expects FROM=TO, got '%s'.",
+          map.c_str());
+    }
+    context.source_prefix_maps.emplace_back(map.substr(0, eq),
+                                            map.substr(eq + 1));
+  }
+  context.no_disassembly = no_disassembly_;
   context.verbose = verbose_;
   if (const char* val = getenv("ANDROID_PRODUCT_OUT"))
     context.android_product_out = val;
diff --git a/src/trace_processor/shell/bundle_subcommand.h b/src/trace_processor/shell/bundle_subcommand.h
index f71687e..f396dd8 100644
--- a/src/trace_processor/shell/bundle_subcommand.h
+++ b/src/trace_processor/shell/bundle_subcommand.h
@@ -39,6 +39,9 @@
  private:
   std::string symbol_paths_;
   bool no_auto_symbol_paths_ = false;
+  bool no_source_files_ = false;
+  std::vector<std::string> source_prefix_maps_;
+  bool no_disassembly_ = false;
   std::vector<std::string> proguard_maps_;
   bool no_auto_proguard_maps_ = false;
   bool verbose_ = false;
diff --git a/src/trace_processor/util/symbolizer/local_symbolizer.cc b/src/trace_processor/util/symbolizer/local_symbolizer.cc
index cb1ef3e..9e49d52 100644
--- a/src/trace_processor/util/symbolizer/local_symbolizer.cc
+++ b/src/trace_processor/util/symbolizer/local_symbolizer.cc
@@ -1111,6 +1111,8 @@
   }
 
   SymbolizeResult result;
+  result.binary_path = binary->file_name;
+  result.address_correction = addr_correction;
   result.frames.reserve(addresses.size());
   for (uint64_t address : addresses) {
     result.frames.emplace_back(llvm_symbolizer_.Symbolize(
diff --git a/src/trace_processor/util/symbolizer/symbolize_database.cc b/src/trace_processor/util/symbolizer/symbolize_database.cc
index 95704eb..61328c5 100644
--- a/src/trace_processor/util/symbolizer/symbolize_database.cc
+++ b/src/trace_processor/util/symbolizer/symbolize_database.cc
@@ -228,9 +228,9 @@
         break;
       }
     }
-    output.successful_mappings.push_back({unsymbolized_mapping.name,
-                                          unsymbolized_mapping.build_id,
-                                          symbol_path, frame_count});
+    output.successful_mappings.push_back(
+        {unsymbolized_mapping.name, unsymbolized_mapping.build_id, symbol_path,
+         frame_count, res.binary_path, res.address_correction});
 
     protozero::HeapBuffered<perfetto::protos::pbzero::Trace> trace;
     auto* packet = trace->add_packet();
diff --git a/src/trace_processor/util/symbolizer/symbolize_database.h b/src/trace_processor/util/symbolizer/symbolize_database.h
index c3e3164..fb2f2de 100644
--- a/src/trace_processor/util/symbolizer/symbolize_database.h
+++ b/src/trace_processor/util/symbolizer/symbolize_database.h
@@ -65,6 +65,11 @@
   std::string symbol_path;
   // Number of frames that were symbolized.
   uint32_t frame_count = 0;
+  // The binary the symbols were read from and the value to add to a
+  // mapping-relative address to obtain its link-time virtual address in it.
+  // See SymbolizeResult. Empty when symbols came from a symbol file.
+  std::string binary_path;
+  uint64_t address_correction = 0;
 };
 
 // Record of a failed symbolization attempt for a mapping.
diff --git a/src/trace_processor/util/symbolizer/symbolizer.h b/src/trace_processor/util/symbolizer/symbolizer.h
index 8e12aef..f5b4461 100644
--- a/src/trace_processor/util/symbolizer/symbolizer.h
+++ b/src/trace_processor/util/symbolizer/symbolizer.h
@@ -63,6 +63,15 @@
   // attempted paths with their individual errors.
   std::vector<SymbolPathAttempt> attempts;
 
+  // The binary the symbols were read from, when symbolization used a binary
+  // on disk rather than a symbol file. Empty otherwise.
+  std::string binary_path;
+
+  // Value to add to a mapping-relative address to obtain the link-time
+  // virtual address in |binary_path|. Only meaningful when |binary_path| is
+  // set.
+  uint64_t address_correction = 0;
+
   // Returns true if symbolization produced frames.
   bool ok() const { return !frames.empty(); }
 };
diff --git a/src/trace_processor/util/trace_enrichment/BUILD.gn b/src/trace_processor/util/trace_enrichment/BUILD.gn
index 87f4b78..10e25a9 100644
--- a/src/trace_processor/util/trace_enrichment/BUILD.gn
+++ b/src/trace_processor/util/trace_enrichment/BUILD.gn
@@ -13,17 +13,49 @@
 # limitations under the License.
 
 import("../../../../gn/perfetto.gni")
+import("../../../../gn/test.gni")
 
 source_set("trace_enrichment") {
   sources = [
+    "disassembly.cc",
+    "disassembly.h",
+    "source_files.cc",
+    "source_files.h",
     "trace_enrichment.cc",
     "trace_enrichment.h",
   ]
   deps = [
+    "../:build_id",
     "../../../../gn:default_deps",
     "../../../../include/perfetto/ext/base",
+    "../../../../include/perfetto/protozero",
     "../../../../include/perfetto/trace_processor:trace_processor",
+    "../../../../protos/perfetto/trace:zero",
+    "../../../../protos/perfetto/trace/profiling:zero",
     "../deobfuscation:deobfuscator",
+    "../symbolizer",
     "../symbolizer:symbolize_database",
   ]
 }
+
+perfetto_unittest_source_set("unittests") {
+  testonly = true
+  deps = [
+    ":trace_enrichment",
+    "../../../../gn:default_deps",
+    "../../../../gn:gtest_and_gmock",
+    "../../../../include/perfetto/ext/base",
+    "../../../../include/perfetto/protozero",
+    "../../../../include/perfetto/trace_processor:trace_processor",
+    "../../../../protos/perfetto/trace:cpp",
+    "../../../../protos/perfetto/trace:zero",
+    "../../../../protos/perfetto/trace/profiling:cpp",
+    "../../../../protos/perfetto/trace/profiling:zero",
+    "../../../base",
+    "../../../base:test_support",
+  ]
+  sources = [
+    "disassembly_unittest.cc",
+    "source_files_unittest.cc",
+  ]
+}
diff --git a/src/trace_processor/util/trace_enrichment/disassembly.cc b/src/trace_processor/util/trace_enrichment/disassembly.cc
new file mode 100644
index 0000000..8da1339
--- /dev/null
+++ b/src/trace_processor/util/trace_enrichment/disassembly.cc
@@ -0,0 +1,671 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/util/trace_enrichment/disassembly.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <map>
+#include <optional>
+#include <set>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/string_utils.h"
+#include "perfetto/ext/base/utils.h"
+#include "perfetto/protozero/scattered_heap_buffer.h"
+#include "perfetto/trace_processor/iterator.h"
+#include "perfetto/trace_processor/trace_processor.h"
+#include "src/trace_processor/util/build_id.h"
+
+#include "protos/perfetto/trace/profiling/profile_common.pbzero.h"
+#include "protos/perfetto/trace/trace.pbzero.h"
+#include "protos/perfetto/trace/trace_packet.pbzero.h"
+
+// Running llvm-nm and llvm-objdump needs subprocesses, which are only
+// available where the local symbolizer is (not in WASM builds).
+#if PERFETTO_BUILDFLAG(PERFETTO_LOCAL_SYMBOLIZER)
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/subprocess.h"
+#include "src/trace_processor/util/symbolizer/local_symbolizer.h"
+#endif
+
+namespace perfetto::trace_processor::util {
+namespace {
+
+std::vector<std::string> SplitLines(const std::string& text) {
+  std::vector<std::string> lines;
+  size_t start = 0;
+  while (start < text.size()) {
+    size_t end = text.find('\n', start);
+    if (end == std::string::npos) {
+      end = text.size();
+    }
+    lines.push_back(text.substr(start, end - start));
+    start = end + 1;
+  }
+  return lines;
+}
+
+std::string Trim(std::string_view s) {
+  return std::string(base::TrimWhitespace(s));
+}
+
+std::vector<std::string> SplitWhitespace(const std::string& s) {
+  std::vector<std::string> tokens;
+  size_t i = 0;
+  while (i < s.size()) {
+    while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) {
+      i++;
+    }
+    size_t start = i;
+    while (i < s.size() && s[i] != ' ' && s[i] != '\t') {
+      i++;
+    }
+    if (i > start) {
+      tokens.push_back(s.substr(start, i - start));
+    }
+  }
+  return tokens;
+}
+
+std::optional<uint8_t> HexByte(std::string_view s) {
+  if (s.size() != 2) {
+    return std::nullopt;
+  }
+  std::optional<uint64_t> value = base::StringToUInt64(std::string(s), 16);
+  if (!value) {
+    return std::nullopt;
+  }
+  return static_cast<uint8_t>(*value);
+}
+
+// Decodes the byte column of an objdump line. x86 prints one token per byte;
+// fixed-width ISAs print each instruction word as a single number, which
+// needs to be converted back to memory (little-endian) order.
+std::string DecodeBytes(const std::vector<std::string>& tokens) {
+  std::string bytes;
+  if (tokens.size() == 1 && tokens[0].size() > 2 && tokens[0].size() % 2 == 0) {
+    const std::string& word = tokens[0];
+    for (size_t i = word.size(); i >= 2; i -= 2) {
+      std::optional<uint8_t> byte =
+          HexByte(std::string_view(word).substr(i - 2, 2));
+      if (!byte) {
+        return "";
+      }
+      bytes.push_back(static_cast<char>(*byte));
+    }
+    return bytes;
+  }
+  for (const std::string& token : tokens) {
+    std::optional<uint8_t> byte = HexByte(token);
+    if (!byte) {
+      return "";
+    }
+    bytes.push_back(static_cast<char>(*byte));
+  }
+  return bytes;
+}
+
+// Whether |mnemonic| transfers control to a statically known address, so
+// that a `0x... <symbol>` operand denotes its target rather than, e.g., the
+// data a RIP-relative load refers to.
+bool IsDirectBranch(const std::string& mnemonic) {
+  if (mnemonic.empty()) {
+    return false;
+  }
+  // x86.
+  if (mnemonic[0] == 'j' || mnemonic == "call" ||
+      base::StartsWith(mnemonic, "loop")) {
+    return true;
+  }
+  // arm64 / arm.
+  if (mnemonic == "b" || mnemonic == "bl" || base::StartsWith(mnemonic, "b.") ||
+      mnemonic == "cbz" || mnemonic == "cbnz" || mnemonic == "tbz" ||
+      mnemonic == "tbnz") {
+    return true;
+  }
+  return false;
+}
+
+// Parses a `0x<addr> <symbol[+0x<off>]>` operand.
+void ParseBranchTarget(const std::string& operands,
+                       DisassembledInstruction* insn) {
+  size_t lt = operands.find('<');
+  size_t gt = operands.find('>', lt == std::string::npos ? 0 : lt);
+  if (lt == std::string::npos || gt == std::string::npos) {
+    return;
+  }
+  // The address is the token immediately before the '<'.
+  std::string before = Trim(std::string_view(operands).substr(0, lt));
+  size_t space = before.find_last_of(" ,");
+  std::string address_str =
+      space == std::string::npos ? before : before.substr(space + 1);
+  if (!base::StartsWith(address_str, "0x")) {
+    return;
+  }
+  std::optional<uint64_t> address =
+      base::StringToUInt64(address_str.substr(2), 16);
+  if (!address) {
+    return;
+  }
+  insn->target_address = *address;
+  std::string symbol = operands.substr(lt + 1, gt - lt - 1);
+  size_t plus = symbol.find('+');
+  if (plus != std::string::npos) {
+    symbol = symbol.substr(0, plus);
+  }
+  insn->target_symbol = symbol;
+}
+
+// Runs |exe| with |args| and returns its stdout, or nullopt if it could not
+// be run or failed.
+std::optional<std::string> RunTool(const std::string& exe,
+                                   std::vector<std::string> args) {
+#if PERFETTO_BUILDFLAG(PERFETTO_LOCAL_SYMBOLIZER)
+  constexpr int kToolTimeoutMs = 120 * 1000;
+  base::Subprocess process;
+  process.args.exec_cmd.push_back(exe);
+  for (std::string& arg : args) {
+    process.args.exec_cmd.push_back(std::move(arg));
+  }
+  process.args.stdout_mode = base::Subprocess::OutputMode::kBuffer;
+  process.args.stderr_mode = base::Subprocess::OutputMode::kDevNull;
+  if (!process.Call(kToolTimeoutMs)) {
+    return std::nullopt;
+  }
+  return std::move(process.output());
+#else
+  base::ignore_result(exe, args);
+  return std::nullopt;
+#endif
+}
+
+// objdump arguments pointing it at |debug_binary| for line information when
+// that is not the binary being disassembled: the .dSYM bundle on Mach-O, the
+// directory holding the split debug file on ELF (found via .gnu_debuglink).
+std::vector<std::string> DebugInfoArgs(const std::string& code_binary,
+                                       const std::string& debug_binary) {
+  if (debug_binary.empty() || debug_binary == code_binary) {
+    return {};
+  }
+  size_t dsym = debug_binary.find(".dSYM/");
+  if (dsym != std::string::npos) {
+    return {"--dsym=" + debug_binary.substr(0, dsym + 5)};
+  }
+  size_t slash = debug_binary.find_last_of('/');
+  if (slash == std::string::npos) {
+    return {};
+  }
+  return {"--debug-file-directory=" + debug_binary.substr(0, slash)};
+}
+
+// |mapping_name| if it is a file on this machine with build id |build_id|
+// (raw), i.e. the very binary the trace was recorded with; empty otherwise.
+std::string VerifiedCodeBinary(const std::string& mapping_name,
+                               const std::string& build_id) {
+#if PERFETTO_BUILDFLAG(PERFETTO_LOCAL_SYMBOLIZER)
+  if (mapping_name.empty() || mapping_name[0] != '/' ||
+      !base::FileExists(mapping_name)) {
+    return "";
+  }
+  profiling::LocalBinaryIndexer indexer({}, {mapping_name});
+  profiling::BinaryLookupResult lookup =
+      indexer.FindBinary(mapping_name, build_id);
+  return lookup.ok() ? lookup.binary->file_name : "";
+#else
+  base::ignore_result(mapping_name, build_id);
+  return "";
+#endif
+}
+
+std::string Hex(uint64_t value) {
+  return "0x" + base::Uint64ToHexStringNoPrefix(value);
+}
+
+std::string SqlQuote(const std::string& value) {
+  std::string out = "'";
+  for (char c : value) {
+    if (c == '\'') {
+      out += "''";
+    } else {
+      out.push_back(c);
+    }
+  }
+  out += "'";
+  return out;
+}
+
+}  // namespace
+
+std::vector<FunctionRange> ParseNmOutput(const std::string& output) {
+  std::vector<FunctionRange> functions;
+  for (const std::string& line : SplitLines(output)) {
+    // `<address> <size> <type> <name>`, or `<address> <type> <name>` for
+    // symbols without a size.
+    std::vector<std::string> tokens = SplitWhitespace(line);
+    if (tokens.size() < 3) {
+      continue;
+    }
+    bool has_size = tokens.size() >= 4;
+    const std::string& type = tokens[has_size ? 2 : 1];
+    if (type != "t" && type != "T" && type != "w" && type != "W") {
+      continue;
+    }
+    std::optional<uint64_t> start = base::StringToUInt64(tokens[0], 16);
+    if (!start) {
+      continue;
+    }
+    uint64_t size = 0;
+    if (has_size) {
+      size = base::StringToUInt64(tokens[1], 16).value_or(0);
+    }
+    functions.push_back({tokens[has_size ? 3 : 2], *start, size});
+  }
+  std::sort(functions.begin(), functions.end(),
+            [](const FunctionRange& a, const FunctionRange& b) {
+              return a.start < b.start;
+            });
+  // Symbol tables do not always carry sizes (Mach-O never does, ELF omits
+  // them for symbols defined in assembly). Take a function to extend to the
+  // next symbol in that case.
+  for (size_t i = 0; i + 1 < functions.size(); i++) {
+    if (functions[i].size == 0) {
+      functions[i].size = functions[i + 1].start - functions[i].start;
+    }
+  }
+  return functions;
+}
+
+std::vector<SectionRange> ParseSectionHeaders(const std::string& output) {
+  std::vector<SectionRange> sections;
+  for (const std::string& line : SplitLines(output)) {
+    // `<idx> <name> <size> <vma> <type flags...>`.
+    std::vector<std::string> tokens = SplitWhitespace(line);
+    if (tokens.size() < 5) {
+      continue;
+    }
+    bool is_code = false;
+    for (size_t i = 4; i < tokens.size(); i++) {
+      if (tokens[i] == "TEXT" || tokens[i] == "TEXT,") {
+        is_code = true;
+      }
+    }
+    std::optional<uint64_t> size = base::StringToUInt64(tokens[2], 16);
+    std::optional<uint64_t> start = base::StringToUInt64(tokens[3], 16);
+    if (!is_code || !size || !start || *size == 0) {
+      continue;
+    }
+    sections.push_back({tokens[1], *start, *size});
+  }
+  return sections;
+}
+
+void ExtendTrailingFunction(std::vector<FunctionRange>* functions,
+                            const std::vector<SectionRange>& sections) {
+  for (FunctionRange& function : *functions) {
+    for (const SectionRange& section : sections) {
+      uint64_t end = section.start + section.size;
+      if (function.start < section.start || function.start >= end) {
+        continue;
+      }
+      // A size inferred from the next symbol must not cross into another
+      // section, and the last symbol of a section extends to its end.
+      if (function.size == 0 || function.start + function.size > end) {
+        function.size = end - function.start;
+      }
+      break;
+    }
+  }
+  functions->erase(
+      std::remove_if(functions->begin(), functions->end(),
+                     [](const FunctionRange& f) { return f.size == 0; }),
+      functions->end());
+}
+
+std::vector<FunctionRange> FunctionsContaining(
+    const std::vector<FunctionRange>& functions,
+    const std::vector<uint64_t>& addresses) {
+  std::map<uint64_t, FunctionRange> found;
+  for (uint64_t address : addresses) {
+    // The last function starting at or before the address.
+    auto it = std::upper_bound(
+        functions.begin(), functions.end(), address,
+        [](uint64_t a, const FunctionRange& f) { return a < f.start; });
+    if (it == functions.begin()) {
+      continue;
+    }
+    --it;
+    if (address >= it->start + it->size) {
+      continue;
+    }
+    found.emplace(it->start, *it);
+  }
+  std::vector<FunctionRange> result;
+  for (auto& [start, function] : found) {
+    result.push_back(std::move(function));
+  }
+  return result;
+}
+
+std::vector<DisassembledInstruction> ParseObjdumpOutput(
+    const std::string& output) {
+  std::vector<DisassembledInstruction> instructions;
+  std::string source_file;
+  uint32_t line_number = 0;
+  for (const std::string& raw_line : SplitLines(output)) {
+    std::string line = Trim(raw_line);
+    if (line.empty()) {
+      continue;
+    }
+    // `-l` emits the location of the following instructions as comments:
+    // `; func():` for the function and `; /path/file.cc:12` for lines.
+    if (line[0] == ';') {
+      std::string comment = Trim(std::string_view(line).substr(1));
+      size_t colon = comment.find_last_of(':');
+      if (base::EndsWith(comment, "():") || colon == std::string::npos) {
+        continue;
+      }
+      std::optional<uint64_t> number =
+          base::StringToUInt64(comment.substr(colon + 1), 10);
+      if (!number) {
+        continue;
+      }
+      source_file = comment.substr(0, colon);
+      line_number = static_cast<uint32_t>(*number);
+      continue;
+    }
+    // Instruction lines are `<addr>: <bytes>\t<mnemonic>\t<operands>`.
+    size_t colon = raw_line.find(':');
+    if (colon == std::string::npos) {
+      continue;
+    }
+    std::optional<uint64_t> address = base::StringToUInt64(
+        Trim(std::string_view(raw_line).substr(0, colon)), 16);
+    if (!address) {
+      continue;
+    }
+    std::vector<std::string> columns;
+    size_t start = colon + 1;
+    while (start <= raw_line.size()) {
+      size_t tab = raw_line.find('\t', start);
+      if (tab == std::string::npos) {
+        tab = raw_line.size();
+      }
+      columns.push_back(raw_line.substr(start, tab - start));
+      start = tab + 1;
+    }
+    if (columns.size() < 2) {
+      continue;
+    }
+    DisassembledInstruction insn;
+    insn.address = *address;
+    insn.bytes = DecodeBytes(SplitWhitespace(columns[0]));
+    if (insn.bytes.empty()) {
+      continue;
+    }
+    std::string mnemonic = Trim(columns[1]);
+    std::string operands = columns.size() > 2 ? Trim(columns[2]) : "";
+    // Drop trailing annotations such as the decimal value of an immediate,
+    // introduced by `//` (ELF) or `;` (Mach-O).
+    size_t comment = std::min(operands.find("//"), operands.find(" ;"));
+    if (comment != std::string::npos) {
+      operands = Trim(std::string_view(operands).substr(0, comment));
+    }
+    insn.text = operands.empty() ? mnemonic : mnemonic + " " + operands;
+    if (IsDirectBranch(mnemonic)) {
+      ParseBranchTarget(operands, &insn);
+    }
+    insn.source_file = source_file;
+    insn.line_number = line_number;
+    instructions.push_back(std::move(insn));
+  }
+  return instructions;
+}
+
+bool LooksLikeCode(const std::vector<DisassembledInstruction>& instructions) {
+  if (instructions.empty()) {
+    return false;
+  }
+  size_t invalid = 0;
+  for (const DisassembledInstruction& insn : instructions) {
+    if (insn.text == "<unknown>" || base::StartsWith(insn.text, "udf ")) {
+      invalid++;
+    }
+  }
+  // Real code has the odd data-in-text word; container bytes decode as
+  // invalid instructions far more often than that.
+  return invalid * 10 < instructions.size();
+}
+
+Disassembler::Disassembler(DisassemblerConfig config)
+    : config_(std::move(config)) {}
+
+std::optional<std::vector<DisassembledFunction>> Disassembler::Disassemble(
+    const std::string& code_binary,
+    const std::string& debug_binary,
+    const std::vector<uint64_t>& addresses) {
+  // Symbols may live in either file: a stripped executable keeps them only
+  // in its debug file, a debug file may lack the ones added by the linker.
+  std::vector<FunctionRange> functions;
+  for (const std::string& binary : {debug_binary, code_binary}) {
+    if (binary.empty() || (binary == code_binary &&
+                           code_binary == debug_binary && !functions.empty())) {
+      continue;
+    }
+    std::optional<std::string> nm_output = RunTool(
+        config_.llvm_nm, {"-S", "--defined-only", "--numeric-sort", binary});
+    if (!nm_output) {
+      return std::nullopt;
+    }
+    for (FunctionRange& function : ParseNmOutput(*nm_output)) {
+      bool known = std::any_of(
+          functions.begin(), functions.end(),
+          [&](const FunctionRange& f) { return f.start == function.start; });
+      if (!known) {
+        functions.push_back(std::move(function));
+      }
+    }
+  }
+  std::sort(functions.begin(), functions.end(),
+            [](const FunctionRange& a, const FunctionRange& b) {
+              return a.start < b.start;
+            });
+  std::optional<std::string> sections_output =
+      RunTool(config_.llvm_objdump, {"--section-headers", code_binary});
+  if (!sections_output) {
+    return std::nullopt;
+  }
+  ExtendTrailingFunction(&functions, ParseSectionHeaders(*sections_output));
+
+  std::vector<std::string> debug_args =
+      DebugInfoArgs(code_binary, debug_binary);
+  std::vector<DisassembledFunction> result;
+  for (const FunctionRange& function :
+       FunctionsContaining(functions, addresses)) {
+    if (function.size > config_.max_function_size) {
+      continue;
+    }
+    std::vector<std::string> args = {
+        "-d",
+        "-l",
+        "--print-imm-hex",
+        "--x86-asm-syntax=intel",
+        "--start-address=" + Hex(function.start),
+        "--stop-address=" + Hex(function.start + function.size)};
+    args.insert(args.end(), debug_args.begin(), debug_args.end());
+    args.push_back(code_binary);
+    std::optional<std::string> objdump_output =
+        RunTool(config_.llvm_objdump, std::move(args));
+    if (!objdump_output) {
+      return std::nullopt;
+    }
+    std::vector<DisassembledInstruction> instructions =
+        ParseObjdumpOutput(*objdump_output);
+    if (instructions.empty() || !LooksLikeCode(instructions)) {
+      continue;
+    }
+    result.push_back({function, std::move(instructions)});
+  }
+  return result;
+}
+
+DisassemblyResult BundleDisassembly(
+    TraceProcessor* tp,
+    const std::vector<DisassemblyBinary>& binaries,
+    const DisassemblerConfig& config) {
+  DisassemblyResult result;
+  Disassembler disassembler(config);
+  for (const DisassemblyBinary& binary : binaries) {
+    std::string build_id_hex = BuildId::FromRaw(binary.build_id).ToHex();
+    auto it = tp->ExecuteQuery(R"(
+      SELECT DISTINCT spf.rel_pc
+      FROM stack_profile_frame spf
+      JOIN stack_profile_mapping spm ON spf.mapping = spm.id
+      WHERE spm.build_id = )" + SqlQuote(build_id_hex) +
+                               " AND spm.name = " +
+                               SqlQuote(binary.mapping_name));
+    std::vector<uint64_t> addresses;
+    while (it.Next()) {
+      addresses.push_back(static_cast<uint64_t>(it.Get(0).AsLong()) +
+                          binary.address_correction);
+    }
+    if (addresses.empty()) {
+      continue;
+    }
+
+    // The symbolizer's binary may hold only debug info; the mapping's own
+    // path is then the code binary if it is the same build.
+    std::vector<std::string> code_candidates = {binary.binary_path};
+    std::string verified =
+        VerifiedCodeBinary(binary.mapping_name, binary.build_id);
+    if (!verified.empty() && verified != binary.binary_path) {
+      code_candidates.push_back(verified);
+    }
+    std::optional<std::vector<DisassembledFunction>> functions;
+    for (const std::string& code_binary : code_candidates) {
+      functions =
+          disassembler.Disassemble(code_binary, binary.binary_path, addresses);
+      if (!functions) {
+        result.tools_unavailable = true;
+        return result;
+      }
+      if (!functions->empty()) {
+        break;
+      }
+    }
+    if (functions->empty()) {
+      result.binaries_without_code.push_back(binary.binary_path);
+      continue;
+    }
+
+    protozero::HeapBuffered<protos::pbzero::Trace> trace;
+    auto* module = trace->add_packet()->set_module_disassembly();
+    module->set_path(binary.mapping_name);
+    module->set_build_id(binary.build_id);
+    // The source file table is a field of the module message, which cannot
+    // be written to while a nested function message is open, so it is built
+    // and emitted before the functions.
+    std::map<std::string, uint32_t> source_file_index;
+    for (const DisassembledFunction& function : *functions) {
+      for (const DisassembledInstruction& insn : function.instructions) {
+        if (!insn.source_file.empty() && insn.line_number != 0) {
+          source_file_index.emplace(
+              insn.source_file,
+              static_cast<uint32_t>(source_file_index.size()));
+        }
+      }
+    }
+    std::vector<const std::string*> source_files(source_file_index.size());
+    for (const auto& [path, index] : source_file_index) {
+      source_files[index] = &path;
+    }
+    for (const std::string* path : source_files) {
+      module->add_source_files(*path);
+    }
+    for (const DisassembledFunction& function : *functions) {
+      auto* fn = module->add_functions();
+      fn->set_name(function.range.name);
+      fn->set_start_address(function.range.start - binary.address_correction);
+      fn->set_size(function.range.size);
+      for (const DisassembledInstruction& insn : function.instructions) {
+        auto* out = fn->add_instructions();
+        out->set_address(insn.address - binary.address_correction);
+        out->set_bytes(insn.bytes);
+        out->set_text(insn.text);
+        if (insn.target_address) {
+          out->set_target_address(*insn.target_address -
+                                  binary.address_correction);
+          bool in_function =
+              *insn.target_address >= function.range.start &&
+              *insn.target_address < function.range.start + function.range.size;
+          if (!in_function && !insn.target_symbol.empty()) {
+            out->set_target_symbol(insn.target_symbol);
+          }
+        }
+        if (!insn.source_file.empty() && insn.line_number != 0) {
+          out->set_source_file_index(source_file_index.at(insn.source_file));
+          out->set_line_number(insn.line_number);
+        }
+      }
+      result.function_count++;
+      result.instruction_count +=
+          static_cast<uint32_t>(function.instructions.size());
+    }
+    result.packets += trace.SerializeAsString();
+  }
+  return result;
+}
+
+std::string FormatDisassemblySummary(const DisassemblyResult& result,
+                                     bool verbose) {
+  if (result.tools_unavailable) {
+    return "Disassembly: skipped, llvm-nm / llvm-objdump could not be run. "
+           "Install LLVM to bundle disassembly.\n";
+  }
+  if (result.function_count == 0 && result.binaries_without_code.empty()) {
+    return "";
+  }
+  std::string out = "Disassembly: " + std::to_string(result.function_count) +
+                    " functions (" + std::to_string(result.instruction_count) +
+                    " instructions)";
+  if (!result.binaries_without_code.empty()) {
+    out += ", " + std::to_string(result.binaries_without_code.size()) +
+           " binaries without code";
+  }
+  out += "\n";
+  if (result.binaries_without_code.empty()) {
+    return out;
+  }
+  if (!verbose) {
+    out +=
+        "  Symbol files without code cannot be disassembled; point "
+        "--symbol-paths at the unstripped binaries. Use --verbose to "
+        "list them.\n";
+    return out;
+  }
+  for (const std::string& path : result.binaries_without_code) {
+    out += "  - no code: " + path + "\n";
+  }
+  return out;
+}
+
+}  // namespace perfetto::trace_processor::util
diff --git a/src/trace_processor/util/trace_enrichment/disassembly.h b/src/trace_processor/util/trace_enrichment/disassembly.h
new file mode 100644
index 0000000..d1862e5
--- /dev/null
+++ b/src/trace_processor/util/trace_enrichment/disassembly.h
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_DISASSEMBLY_H_
+#define SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_DISASSEMBLY_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace perfetto::trace_processor {
+class TraceProcessor;
+}
+
+namespace perfetto::trace_processor::util {
+
+// A function in a binary's symbol table. Addresses are link-time virtual
+// addresses, i.e. the ones llvm tools print.
+struct FunctionRange {
+  std::string name;
+  uint64_t start = 0;
+  uint64_t size = 0;
+};
+
+struct DisassembledInstruction {
+  uint64_t address = 0;
+
+  // Raw encoded bytes of the instruction.
+  std::string bytes;
+
+  // Mnemonic and operands.
+  std::string text;
+
+  // For direct branches and calls, the target address and, when the
+  // disassembler named it, the symbol at the target.
+  std::optional<uint64_t> target_address;
+  std::string target_symbol;
+
+  // Source location, empty / zero when unknown.
+  std::string source_file;
+  uint32_t line_number = 0;
+};
+
+struct DisassembledFunction {
+  FunctionRange range;
+  std::vector<DisassembledInstruction> instructions;
+};
+
+// Parses the output of `llvm-nm -S --defined-only --numeric-sort`, keeping
+// code symbols. Symbols without a size extend to the next symbol; the last
+// symbol keeps size 0 until ExtendTrailingFunction sees the section it is
+// in. The result is sorted by start address.
+std::vector<FunctionRange> ParseNmOutput(const std::string& output);
+
+// A code section of a binary, from `llvm-objdump --section-headers`.
+struct SectionRange {
+  std::string name;
+  uint64_t start = 0;
+  uint64_t size = 0;
+};
+
+// Parses the output of `llvm-objdump --section-headers`, keeping the code
+// sections.
+std::vector<SectionRange> ParseSectionHeaders(const std::string& output);
+
+// Gives functions without a size (the last symbol of a file) the extent up to
+// the end of the section containing them, and drops those in no section.
+void ExtendTrailingFunction(std::vector<FunctionRange>* functions,
+                            const std::vector<SectionRange>& sections);
+
+// Returns the functions in |functions| (sorted by start address) which contain
+// any of |addresses|, each once, in address order.
+std::vector<FunctionRange> FunctionsContaining(
+    const std::vector<FunctionRange>& functions,
+    const std::vector<uint64_t>& addresses);
+
+// Parses the output of `llvm-objdump -d -l --print-imm-hex` for one function.
+std::vector<DisassembledInstruction> ParseObjdumpOutput(
+    const std::string& output);
+
+// Whether |instructions| plausibly decode real code. Disassembling a file
+// which holds only debug info yields nothing for ELF but, for Mach-O, decodes
+// the container's bytes as instructions, most of which are invalid.
+bool LooksLikeCode(const std::vector<DisassembledInstruction>& instructions);
+
+struct DisassemblerConfig {
+  std::string llvm_nm = "llvm-nm";
+  std::string llvm_objdump = "llvm-objdump";
+
+  // Functions larger than this are not disassembled.
+  uint64_t max_function_size = uint64_t{128} << 10;
+};
+
+// Disassembles functions of binaries on disk using llvm-nm and llvm-objdump.
+class Disassembler {
+ public:
+  explicit Disassembler(DisassemblerConfig config);
+
+  // Disassembles the functions of |code_binary| which contain any of
+  // |addresses| (link-time virtual addresses), taking symbols and source
+  // lines from |debug_binary| as well, which may be the same file or a split
+  // debug file (a .dSYM bundle member or an ELF holding only debug info).
+  // Functions with no code, e.g. when |code_binary| holds only debug info,
+  // are omitted. Returns nullopt if the tools could not be run.
+  std::optional<std::vector<DisassembledFunction>> Disassemble(
+      const std::string& code_binary,
+      const std::string& debug_binary,
+      const std::vector<uint64_t>& addresses);
+
+ private:
+  DisassemblerConfig config_;
+};
+
+// A binary on disk together with the mapping it was loaded as in the trace.
+struct DisassemblyBinary {
+  std::string mapping_name;
+  // Raw (not hex encoded) build id.
+  std::string build_id;
+  std::string binary_path;
+  // Value to add to a mapping-relative address to obtain its link-time
+  // virtual address in |binary_path|.
+  uint64_t address_correction = 0;
+};
+
+struct DisassemblyResult {
+  // Serialized TracePacket protos containing ModuleDisassembly packets.
+  // Ready to be appended to the trace or included in a bundle.
+  std::string packets;
+
+  uint32_t function_count = 0;
+  uint32_t instruction_count = 0;
+
+  // Binaries in which no sampled function had code, e.g. files holding only
+  // debug info.
+  std::vector<std::string> binaries_without_code;
+
+  // True if llvm-nm or llvm-objdump could not be run.
+  bool tools_unavailable = false;
+};
+
+// For each of |binaries|, disassembles the functions containing the addresses
+// of the frames in |tp| that belong to its mapping.
+DisassemblyResult BundleDisassembly(
+    TraceProcessor* tp,
+    const std::vector<DisassemblyBinary>& binaries,
+    const DisassemblerConfig& config);
+
+// Formats a human-readable summary of |result|. Returns an empty string if
+// there was nothing to disassemble.
+std::string FormatDisassemblySummary(const DisassemblyResult& result,
+                                     bool verbose);
+
+}  // namespace perfetto::trace_processor::util
+
+#endif  // SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_DISASSEMBLY_H_
diff --git a/src/trace_processor/util/trace_enrichment/disassembly_unittest.cc b/src/trace_processor/util/trace_enrichment/disassembly_unittest.cc
new file mode 100644
index 0000000..908e77a
--- /dev/null
+++ b/src/trace_processor/util/trace_enrichment/disassembly_unittest.cc
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/util/trace_enrichment/disassembly.h"
+
+#include <string>
+#include <vector>
+
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::trace_processor::util {
+namespace {
+
+using ::testing::ElementsAre;
+using ::testing::Field;
+using ::testing::IsEmpty;
+
+constexpr char kNmOutput[] =
+    "0000000000401000 0000000000000006 T helper\n"
+    "0000000000401010 0000000000000060 T compute\n"
+    "0000000000401070 t local_helper\n"
+    "0000000000401080 0000000000000052 W weak_fn\n"
+    "0000000000404000 0000000000000004 D data\n"
+    "0000000000401078 0000000000000000 t empty_fn\n";
+
+TEST(DisassemblyTest, ParseNmOutputKeepsCodeSymbolsInAddressOrder) {
+  std::vector<FunctionRange> functions = ParseNmOutput(kNmOutput);
+  EXPECT_THAT(functions,
+              ElementsAre(Field(&FunctionRange::name, "helper"),
+                          Field(&FunctionRange::name, "compute"),
+                          Field(&FunctionRange::name, "local_helper"),
+                          Field(&FunctionRange::name, "empty_fn"),
+                          Field(&FunctionRange::name, "weak_fn")));
+  EXPECT_EQ(functions[1].start, 0x401010u);
+  EXPECT_EQ(functions[1].size, 0x60u);
+  // Symbols without a size extend to the next symbol.
+  EXPECT_EQ(functions[2].size, 0x8u);
+  EXPECT_EQ(functions[3].size, 0x8u);
+}
+
+TEST(DisassemblyTest, ParseNmOutputKeepsTrailingSymbolWithoutSize) {
+  std::vector<FunctionRange> functions =
+      ParseNmOutput("0000000000401000 T only\n");
+  ASSERT_EQ(functions.size(), 1u);
+  EXPECT_EQ(functions[0].size, 0u);
+}
+
+// Output of `llvm-objdump --section-headers`.
+constexpr char kSectionHeaders[] =
+    "\n"
+    "t:\tfile format mach-o arm64\n"
+    "\n"
+    "Sections:\n"
+    "Idx Name          Size     VMA              Type\n"
+    "  0 __text        00000090 0000000100000328 TEXT\n"
+    "  1 __unwind_info 00000060 00000001000003b8 DATA\n"
+    "  2 .text         00001000 0000000000401000 TEXT, BSS\n";
+
+TEST(DisassemblyTest, ParseSectionHeadersKeepsCodeSections) {
+  std::vector<SectionRange> sections = ParseSectionHeaders(kSectionHeaders);
+  EXPECT_THAT(sections, ElementsAre(Field(&SectionRange::name, "__text"),
+                                    Field(&SectionRange::name, ".text")));
+  EXPECT_EQ(sections[0].start, 0x100000328u);
+  EXPECT_EQ(sections[0].size, 0x90u);
+}
+
+TEST(DisassemblyTest, ExtendTrailingFunctionUsesSectionEnd) {
+  std::vector<FunctionRange> functions = ParseNmOutput(
+      "0000000100000328 0000000000000000 T _helper\n"
+      "0000000100000334 0000000000000000 T _compute\n"
+      "00000001000003b4 0000000000000000 T _main\n"
+      "0000000200000000 0000000000000000 T _orphan\n");
+  ExtendTrailingFunction(&functions, ParseSectionHeaders(kSectionHeaders));
+  EXPECT_THAT(functions, ElementsAre(Field(&FunctionRange::name, "_helper"),
+                                     Field(&FunctionRange::name, "_compute"),
+                                     Field(&FunctionRange::name, "_main")));
+  // _main extends to the end of __text (0x100000328 + 0x90).
+  EXPECT_EQ(functions[2].size, 0x4u);
+}
+
+TEST(DisassemblyTest, FunctionsContainingDedupesAndOrders) {
+  std::vector<FunctionRange> functions = ParseNmOutput(kNmOutput);
+  // Two addresses in compute, one in weak_fn, one in local_helper, one in a
+  // gap after weak_fn and one before all functions.
+  std::vector<FunctionRange> found = FunctionsContaining(
+      functions, {0x401090, 0x401020, 0x40106f, 0x401072, 0x4010e0, 0x100});
+  EXPECT_THAT(found, ElementsAre(Field(&FunctionRange::name, "compute"),
+                                 Field(&FunctionRange::name, "local_helper"),
+                                 Field(&FunctionRange::name, "weak_fn")));
+}
+
+// Output of `llvm-objdump -d -l --print-imm-hex --x86-asm-syntax=intel`.
+constexpr char kX86Output[] =
+    "\n"
+    "t.o:\tfile format elf64-x86-64\n"
+    "\n"
+    "Disassembly of section .text:\n"
+    "\n"
+    "0000000000401010 <compute>:\n"
+    "; compute():\n"
+    "; /src/t.c:4\n"
+    "  401010: 85 ff                        \ttest\tedi, edi\n"
+    "  401012: 7e 59                        \tjle\t0x40106d <compute+0x5d>\n"
+    "; /src/t.c:5\n"
+    "  401014: e8 e7 ff ff ff               \tcall\t0x401000 <helper>\n"
+    "  401019: 48 8d 05 e0 2f 00 00         \tlea\trax, [rip + 0x2fe0]"
+    "  # 0x404000 <data>\n"
+    "; /src/t.c:8\n"
+    "  401020: c3                           \tret\n";
+
+TEST(DisassemblyTest, ParseObjdumpOutputX86) {
+  std::vector<DisassembledInstruction> insns = ParseObjdumpOutput(kX86Output);
+  ASSERT_EQ(insns.size(), 5u);
+
+  EXPECT_EQ(insns[0].address, 0x401010u);
+  EXPECT_EQ(insns[0].bytes, std::string("\x85\xff", 2));
+  EXPECT_EQ(insns[0].text, "test edi, edi");
+  EXPECT_FALSE(insns[0].target_address.has_value());
+  EXPECT_EQ(insns[0].source_file, "/src/t.c");
+  EXPECT_EQ(insns[0].line_number, 4u);
+
+  EXPECT_EQ(insns[1].text, "jle 0x40106d <compute+0x5d>");
+  EXPECT_EQ(insns[1].target_address, 0x40106du);
+  EXPECT_EQ(insns[1].target_symbol, "compute");
+
+  EXPECT_EQ(insns[2].bytes, std::string("\xe8\xe7\xff\xff\xff", 5));
+  EXPECT_EQ(insns[2].target_address, 0x401000u);
+  EXPECT_EQ(insns[2].target_symbol, "helper");
+  EXPECT_EQ(insns[2].line_number, 5u);
+
+  // A RIP-relative data reference is annotated like a branch target but is
+  // not one.
+  EXPECT_FALSE(insns[3].target_address.has_value());
+
+  EXPECT_EQ(insns[4].text, "ret");
+  EXPECT_EQ(insns[4].line_number, 8u);
+}
+
+// Output of `llvm-objdump -d -l --print-imm-hex` for arm64, where each
+// instruction is printed as one 32-bit word.
+constexpr char kArm64Output[] =
+    "000000000040000c <compute>:\n"
+    "; compute():\n"
+    "; /src/t.c:4\n"
+    "  40000c: 7100041f     \tcmp\tw0, #0x1\n"
+    "  400010: 540003ab     \tb.lt\t0x400084 <compute+0x78>\n"
+    "  400024: 52807d08     \tmov\tw8, #0x3e8              // =1000\n"
+    "  400064: 94000000     \tbl\t0x400000 <helper>\n"
+    "  400070: d65f03c0     \tret\n";
+
+TEST(DisassemblyTest, ParseObjdumpOutputArm64) {
+  std::vector<DisassembledInstruction> insns = ParseObjdumpOutput(kArm64Output);
+  ASSERT_EQ(insns.size(), 5u);
+  // Words are converted to little-endian memory order.
+  EXPECT_EQ(insns[0].bytes, std::string("\x1f\x04\x00\x71", 4));
+  EXPECT_EQ(insns[1].text, "b.lt 0x400084 <compute+0x78>");
+  EXPECT_EQ(insns[1].target_address, 0x400084u);
+  EXPECT_EQ(insns[2].text, "mov w8, #0x3e8");
+  EXPECT_EQ(insns[3].target_symbol, "helper");
+  EXPECT_EQ(insns[4].bytes, std::string("\xc0\x03\x5f\xd6", 4));
+}
+
+TEST(DisassemblyTest, ParseObjdumpOutputStripsMachOComments) {
+  std::vector<DisassembledInstruction> insns =
+      ParseObjdumpOutput("100000358: 087d8052    \tmov\tw8, #0x3e8 ; =1000\n");
+  ASSERT_EQ(insns.size(), 1u);
+  EXPECT_EQ(insns[0].text, "mov w8, #0x3e8");
+}
+
+TEST(DisassemblyTest, LooksLikeCodeRejectsDecodedDebugInfo) {
+  EXPECT_TRUE(LooksLikeCode(ParseObjdumpOutput(kArm64Output)));
+  // What objdump makes of a dSYM: the container decoded as instructions.
+  std::vector<DisassembledInstruction> garbage = ParseObjdumpOutput(
+      "100000334: 0000000a    \tudf\t#0xa\n"
+      "100000338: 00000007    \tudf\t#0x7\n"
+      "10000033c: 5de226eb    \t<unknown>\n"
+      "100000340: 383f637a    \tldumaxb\twzr, w26, [x27]\n");
+  EXPECT_EQ(garbage.size(), 4u);
+  EXPECT_FALSE(LooksLikeCode(garbage));
+  EXPECT_FALSE(LooksLikeCode({}));
+}
+
+TEST(DisassemblyTest, ParseObjdumpOutputWithoutCode) {
+  EXPECT_THAT(ParseObjdumpOutput("t.debug:\tfile format elf64-x86-64\n\n"),
+              IsEmpty());
+}
+
+}  // namespace
+}  // namespace perfetto::trace_processor::util
diff --git a/src/trace_processor/util/trace_enrichment/source_files.cc b/src/trace_processor/util/trace_enrichment/source_files.cc
new file mode 100644
index 0000000..bf640e9
--- /dev/null
+++ b/src/trace_processor/util/trace_enrichment/source_files.cc
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/util/trace_enrichment/source_files.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <set>
+#include <string>
+#include <vector>
+
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/protozero/scattered_heap_buffer.h"
+#include "perfetto/trace_processor/iterator.h"
+#include "perfetto/trace_processor/trace_processor.h"
+
+#include "protos/perfetto/trace/profiling/profile_common.pbzero.h"
+#include "protos/perfetto/trace/trace.pbzero.h"
+#include "protos/perfetto/trace/trace_packet.pbzero.h"
+
+namespace perfetto::trace_processor::util {
+namespace {
+
+// Whether a path from debug info can be read from this machine: symbolizers
+// report unknown files as "??" and relative paths cannot be resolved without
+// knowing the build directory.
+bool IsAbsolutePath(const std::string& path) {
+  if (path.empty()) {
+    return false;
+  }
+  if (path[0] == '/') {
+    return true;
+  }
+  // Windows drive letter, e.g. C:\foo.
+  return path.size() > 2 && path[1] == ':' &&
+         (path[2] == '\\' || path[2] == '/');
+}
+
+void AddPath(std::set<std::string>& paths, std::string path) {
+  if (IsAbsolutePath(path)) {
+    paths.insert(std::move(path));
+  }
+}
+
+std::string FormatBytes(size_t bytes) {
+  if (bytes >= 1024 * 1024) {
+    return std::to_string(bytes / (1024 * 1024)) + " MB";
+  }
+  return std::to_string(bytes / 1024) + " KB";
+}
+
+}  // namespace
+
+std::vector<std::string> CollectSourcePaths(TraceProcessor* tp,
+                                            const std::string& symbols_proto) {
+  std::set<std::string> paths;
+
+  auto it = tp->ExecuteQuery(R"(
+    SELECT DISTINCT source_file
+    FROM stack_profile_symbol
+    WHERE source_file IS NOT NULL
+  )");
+  while (it.Next()) {
+    AddPath(paths, it.Get(0).AsString());
+  }
+
+  // |symbols_proto| may be several serialized Trace messages back to back;
+  // decoding them as one message concatenates their packets.
+  protos::pbzero::Trace::Decoder trace(symbols_proto);
+  for (auto packet_it = trace.packet(); packet_it; ++packet_it) {
+    protos::pbzero::TracePacket::Decoder packet(*packet_it);
+    if (!packet.has_module_symbols()) {
+      continue;
+    }
+    protos::pbzero::ModuleSymbols::Decoder module(packet.module_symbols());
+    for (auto address_it = module.address_symbols(); address_it; ++address_it) {
+      protos::pbzero::AddressSymbols::Decoder address(*address_it);
+      for (auto line_it = address.lines(); line_it; ++line_it) {
+        protos::pbzero::Line::Decoder line(*line_it);
+        AddPath(paths, line.source_file_name().ToStdString());
+      }
+    }
+  }
+  return {paths.begin(), paths.end()};
+}
+
+// The path to read |path| from, after applying the prefix maps.
+std::string ResolvePath(const std::string& path,
+                        const SourceFilesConfig& config) {
+  for (const auto& [from, to] : config.prefix_maps) {
+    if (path.compare(0, from.size(), from) == 0) {
+      return to + path.substr(from.size());
+    }
+  }
+  return path;
+}
+
+SourceFilesResult BundleSourceFiles(const std::vector<std::string>& paths,
+                                    const SourceFilesConfig& config) {
+  SourceFilesResult result;
+  for (const std::string& path : paths) {
+    std::string resolved = ResolvePath(path, config);
+    std::optional<uint64_t> size = base::GetFileSize(resolved);
+    if (!size) {
+      result.unreadable.push_back(path);
+      continue;
+    }
+    if (*size > config.max_file_bytes ||
+        result.bundled_bytes + *size > config.max_total_bytes) {
+      result.skipped.push_back(path);
+      continue;
+    }
+    std::string contents;
+    if (!base::ReadFile(resolved, &contents)) {
+      result.unreadable.push_back(path);
+      continue;
+    }
+
+    protozero::HeapBuffered<protos::pbzero::Trace> trace;
+    auto* file = trace->add_packet()->set_source_file();
+    file->set_path(path);
+    file->set_contents(contents);
+    result.packets += trace.SerializeAsString();
+    result.bundled_count++;
+    result.bundled_bytes += contents.size();
+  }
+  return result;
+}
+
+std::string FormatSourceFilesSummary(const SourceFilesResult& result,
+                                     bool verbose) {
+  if (result.bundled_count == 0 && result.unreadable.empty() &&
+      result.skipped.empty()) {
+    return "";
+  }
+  std::string out = "Source files: " + std::to_string(result.bundled_count) +
+                    " bundled (" + FormatBytes(result.bundled_bytes) + ")";
+  if (!result.unreadable.empty()) {
+    out += ", " + std::to_string(result.unreadable.size()) + " not found";
+  }
+  if (!result.skipped.empty()) {
+    out += ", " + std::to_string(result.skipped.size()) + " too large";
+  }
+  out += "\n";
+  if (!verbose) {
+    if (!result.unreadable.empty() || !result.skipped.empty()) {
+      out += "  Use --verbose to list the files which were not bundled.\n";
+    }
+    return out;
+  }
+  for (const std::string& path : result.unreadable) {
+    out += "  - not found: " + path + "\n";
+  }
+  for (const std::string& path : result.skipped) {
+    out += "  - too large: " + path + "\n";
+  }
+  return out;
+}
+
+}  // namespace perfetto::trace_processor::util
diff --git a/src/trace_processor/util/trace_enrichment/source_files.h b/src/trace_processor/util/trace_enrichment/source_files.h
new file mode 100644
index 0000000..b36d2f7
--- /dev/null
+++ b/src/trace_processor/util/trace_enrichment/source_files.h
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_SOURCE_FILES_H_
+#define SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_SOURCE_FILES_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace perfetto::trace_processor {
+class TraceProcessor;
+}
+
+namespace perfetto::trace_processor::util {
+
+struct SourceFilesConfig {
+  // (from, to) prefix pairs: a path starting with `from` is read from the
+  // same path under `to`, for sources built in a different location. The
+  // first matching pair wins. Bundled files keep the path from the debug
+  // info as their key.
+  std::vector<std::pair<std::string, std::string>> prefix_maps;
+
+  // Files larger than this are not bundled.
+  size_t max_file_bytes = size_t{1} << 20;
+
+  // No further files are bundled once this many bytes of source have been
+  // added.
+  size_t max_total_bytes = size_t{16} << 20;
+};
+
+struct SourceFilesResult {
+  // Serialized TracePacket protos containing SourceFile packets.
+  // Ready to be appended to the trace or included in a bundle.
+  std::string packets;
+
+  uint32_t bundled_count = 0;
+  size_t bundled_bytes = 0;
+
+  // Paths which do not exist or could not be read.
+  std::vector<std::string> unreadable;
+
+  // Paths which were not bundled because they exceed the size limits.
+  std::vector<std::string> skipped;
+};
+
+// Returns the distinct source file paths referenced by symbolized frames,
+// both those already in the trace (stack_profile_symbol) and those in
+// |symbols_proto|, a stream of serialized TracePacket protos carrying
+// ModuleSymbols as produced by SymbolizeDatabase. Only absolute paths are
+// returned as relative ones cannot be resolved without the build directory.
+std::vector<std::string> CollectSourcePaths(TraceProcessor* tp,
+                                            const std::string& symbols_proto);
+
+// Reads each of |paths| and serializes its contents as a SourceFile packet.
+SourceFilesResult BundleSourceFiles(const std::vector<std::string>& paths,
+                                    const SourceFilesConfig& config);
+
+// Formats a human-readable summary of |result|. Returns an empty string if
+// there was nothing to bundle.
+std::string FormatSourceFilesSummary(const SourceFilesResult& result,
+                                     bool verbose);
+
+}  // namespace perfetto::trace_processor::util
+
+#endif  // SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_SOURCE_FILES_H_
diff --git a/src/trace_processor/util/trace_enrichment/source_files_unittest.cc b/src/trace_processor/util/trace_enrichment/source_files_unittest.cc
new file mode 100644
index 0000000..495fa78
--- /dev/null
+++ b/src/trace_processor/util/trace_enrichment/source_files_unittest.cc
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/util/trace_enrichment/source_files.h"
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/scoped_file.h"
+#include "perfetto/ext/base/temp_file.h"
+#include "perfetto/protozero/scattered_heap_buffer.h"
+#include "perfetto/trace_processor/basic_types.h"
+#include "perfetto/trace_processor/trace_processor.h"
+#include "test/gtest_and_gmock.h"
+
+#include "protos/perfetto/trace/profiling/profile_common.gen.h"
+#include "protos/perfetto/trace/profiling/profile_common.pbzero.h"
+#include "protos/perfetto/trace/trace.gen.h"
+#include "protos/perfetto/trace/trace.pbzero.h"
+#include "protos/perfetto/trace/trace_packet.gen.h"
+#include "protos/perfetto/trace/trace_packet.pbzero.h"
+
+namespace perfetto::trace_processor::util {
+namespace {
+
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+
+// A temporary directory whose files are removed on destruction, as TempDir
+// requires the directory to be empty when it is deleted.
+class SourceTree {
+ public:
+  ~SourceTree() {
+    for (const std::string& path : files_) {
+      remove(path.c_str());
+    }
+  }
+
+  std::string Write(const std::string& name, const std::string& contents) {
+    std::string path = dir_.path() + "/" + name;
+    base::ScopedFile fd = base::OpenFile(path, O_WRONLY | O_CREAT, 0644);
+    PERFETTO_CHECK(fd);
+    PERFETTO_CHECK(base::WriteAll(*fd, contents.data(), contents.size()) ==
+                   static_cast<ssize_t>(contents.size()));
+    files_.push_back(path);
+    return path;
+  }
+
+  std::string path() const { return dir_.path(); }
+
+ private:
+  base::TempDir dir_ = base::TempDir::Create();
+  std::vector<std::string> files_;
+};
+
+std::string SymbolsProtoWithFiles(const std::vector<std::string>& files) {
+  protozero::HeapBuffered<protos::pbzero::Trace> trace;
+  auto* module = trace->add_packet()->set_module_symbols();
+  module->set_path("/lib.so");
+  for (const std::string& file : files) {
+    auto* address = module->add_address_symbols();
+    address->set_address(0x1000);
+    auto* line = address->add_lines();
+    line->set_function_name("f");
+    line->set_source_file_name(file);
+    line->set_line_number(1);
+  }
+  return trace.SerializeAsString();
+}
+
+TEST(SourceFilesTest, CollectSourcePathsFromSymbolsProto) {
+  auto tp = TraceProcessor::CreateInstance(Config());
+  // Two serialized Trace messages back to back, with a duplicate, a relative
+  // path and a symbolizer placeholder.
+  std::string proto = SymbolsProtoWithFiles({"/src/b.cc", "??"}) +
+                      SymbolsProtoWithFiles({"/src/a.cc", "/src/b.cc", "c.cc"});
+  EXPECT_THAT(CollectSourcePaths(tp.get(), proto),
+              ElementsAre("/src/a.cc", "/src/b.cc"));
+}
+
+TEST(SourceFilesTest, BundleSourceFilesEmitsPackets) {
+  SourceTree dir;
+  std::string a = dir.Write("a.cc", "int a() { return 1; }\n");
+  std::string b = dir.Write("b.cc", "int b() { return 2; }\n");
+
+  SourceFilesResult result = BundleSourceFiles({a, b}, SourceFilesConfig());
+  EXPECT_EQ(result.bundled_count, 2u);
+  EXPECT_THAT(result.unreadable, IsEmpty());
+  EXPECT_THAT(result.skipped, IsEmpty());
+
+  protos::gen::Trace trace;
+  ASSERT_TRUE(trace.ParseFromString(result.packets));
+  ASSERT_EQ(trace.packet().size(), 2u);
+  EXPECT_EQ(trace.packet()[0].source_file().path(), a);
+  EXPECT_EQ(trace.packet()[0].source_file().contents(),
+            "int a() { return 1; }\n");
+  EXPECT_EQ(trace.packet()[1].source_file().path(), b);
+}
+
+TEST(SourceFilesTest, BundleSourceFilesRespectsLimits) {
+  SourceTree dir;
+  std::string small = dir.Write("small.cc", "x");
+  std::string big = dir.Write("big.cc", std::string(100, 'x'));
+  std::string missing = dir.path() + "/missing.cc";
+
+  SourceFilesConfig config;
+  config.max_file_bytes = 10;
+  SourceFilesResult result = BundleSourceFiles({small, big, missing}, config);
+  EXPECT_EQ(result.bundled_count, 1u);
+  EXPECT_EQ(result.bundled_bytes, 1u);
+  EXPECT_THAT(result.skipped, ElementsAre(big));
+  EXPECT_THAT(result.unreadable, ElementsAre(missing));
+}
+
+TEST(SourceFilesTest, BundleSourceFilesAppliesPrefixMap) {
+  SourceTree dir;
+  std::string a = dir.Write("a.cc", "int a;\n");
+
+  SourceFilesConfig config;
+  config.prefix_maps = {{"/nonexistent/other", "/nope"},
+                        {"/build/src", dir.path()}};
+  SourceFilesResult result =
+      BundleSourceFiles({"/build/src/a.cc", "/build/src/missing.cc"}, config);
+  EXPECT_EQ(result.bundled_count, 1u);
+  EXPECT_THAT(result.unreadable, ElementsAre("/build/src/missing.cc"));
+
+  // The bundled path is the one from the debug info, not where it was read.
+  protos::gen::Trace trace;
+  ASSERT_TRUE(trace.ParseFromString(result.packets));
+  ASSERT_EQ(trace.packet().size(), 1u);
+  EXPECT_EQ(trace.packet()[0].source_file().path(), "/build/src/a.cc");
+  EXPECT_EQ(trace.packet()[0].source_file().contents(), "int a;\n");
+}
+
+TEST(SourceFilesTest, BundleSourceFilesRespectsTotalBudget) {
+  SourceTree dir;
+  std::string a = dir.Write("a.cc", "aaaa");
+  std::string b = dir.Write("b.cc", "bbbb");
+
+  SourceFilesConfig config;
+  config.max_total_bytes = 6;
+  SourceFilesResult result = BundleSourceFiles({a, b}, config);
+  EXPECT_EQ(result.bundled_count, 1u);
+  EXPECT_THAT(result.skipped, ElementsAre(b));
+}
+
+}  // namespace
+}  // namespace perfetto::trace_processor::util
diff --git a/src/trace_processor/util/trace_enrichment/trace_enrichment.cc b/src/trace_processor/util/trace_enrichment/trace_enrichment.cc
index ab051e7..4dba6d3 100644
--- a/src/trace_processor/util/trace_enrichment/trace_enrichment.cc
+++ b/src/trace_processor/util/trace_enrichment/trace_enrichment.cc
@@ -26,6 +26,8 @@
 #include "perfetto/trace_processor/trace_processor.h"
 #include "src/trace_processor/util/deobfuscation/deobfuscator.h"
 #include "src/trace_processor/util/symbolizer/symbolize_database.h"
+#include "src/trace_processor/util/trace_enrichment/disassembly.h"
+#include "src/trace_processor/util/trace_enrichment/source_files.h"
 
 namespace perfetto::trace_processor::util {
 
@@ -181,6 +183,9 @@
                              const EnrichmentConfig& config) {
   EnrichmentResult result;
 
+  // Binaries found by symbolization, disassembled below.
+  std::vector<DisassemblyBinary> disassembly_binaries;
+
   // === Native Symbolization ===
   {
     profiling::SymbolizerConfig sym_config;
@@ -211,6 +216,13 @@
     auto sym_result = profiling::SymbolizeDatabase(tp, sym_config);
     if (sym_result.error == profiling::SymbolizerError::kOk) {
       result.native_symbols = std::move(sym_result.symbols);
+      for (const profiling::SuccessfulMapping& m :
+           sym_result.successful_mappings) {
+        if (!m.binary_path.empty()) {
+          disassembly_binaries.push_back({m.mapping_name, m.build_id,
+                                          m.binary_path, m.address_correction});
+        }
+      }
       std::string sym_summary = profiling::FormatSymbolizationSummary(
           sym_result, config.verbose, config.colorize);
       if (!sym_summary.empty()) {
@@ -221,6 +233,27 @@
     }
   }
 
+  // === Source files ===
+  // Uses the symbols produced above as well as any symbolization already in
+  // the trace, so this runs even when offline symbolization found nothing.
+  if (!config.no_source_files) {
+    std::vector<std::string> paths =
+        CollectSourcePaths(tp, result.native_symbols);
+    SourceFilesConfig files_config;
+    files_config.prefix_maps = config.source_prefix_maps;
+    SourceFilesResult files = BundleSourceFiles(paths, files_config);
+    result.source_files = std::move(files.packets);
+    result.details += FormatSourceFilesSummary(files, config.verbose);
+  }
+
+  // === Disassembly ===
+  if (!config.no_disassembly && !disassembly_binaries.empty()) {
+    DisassemblyResult disassembly =
+        BundleDisassembly(tp, disassembly_binaries, DisassemblerConfig());
+    result.disassembly = std::move(disassembly.packets);
+    result.details += FormatDisassemblySummary(disassembly, config.verbose);
+  }
+
   // === Kernel ftrace events that cannot be symbolized offline ===
   // Do this even when symbolization itself was skipped: the user needs this
   // feedback regardless of whether any symbol paths were configured.
diff --git a/src/trace_processor/util/trace_enrichment/trace_enrichment.h b/src/trace_processor/util/trace_enrichment/trace_enrichment.h
index d006d01..c484a3d 100644
--- a/src/trace_processor/util/trace_enrichment/trace_enrichment.h
+++ b/src/trace_processor/util/trace_enrichment/trace_enrichment.h
@@ -18,6 +18,7 @@
 #define SRC_TRACE_PROCESSOR_UTIL_TRACE_ENRICHMENT_TRACE_ENRICHMENT_H_
 
 #include <string>
+#include <utility>
 #include <vector>
 
 namespace perfetto::trace_processor {
@@ -52,6 +53,18 @@
   // PERFETTO_PROGUARD_MAP is always respected.
   bool no_auto_proguard_maps = false;
 
+  // If true, the source files referenced by symbolized frames are not read
+  // from disk and bundled.
+  bool no_source_files = false;
+
+  // (from, to) prefix pairs: a source file whose path in the debug info
+  // starts with `from` is read from the same path under `to`.
+  std::vector<std::pair<std::string, std::string>> source_prefix_maps;
+
+  // If true, the functions containing sampled addresses are not disassembled
+  // and bundled.
+  bool no_disassembly = false;
+
   // If true, output verbose details (all paths tried, etc.).
   // If false, output a concise summary with hint to use --verbose for failures.
   bool verbose = false;
@@ -91,9 +104,20 @@
   // Ready to be appended to the trace or included in a bundle.
   std::string deobfuscation_data;
 
+  // Serialized TracePacket protos containing the source files referenced by
+  // symbolized frames. Ready to be appended to the trace or included in a
+  // bundle.
+  std::string source_files;
+
+  // Serialized TracePacket protos containing the disassembly of the functions
+  // containing sampled addresses. Ready to be appended to the trace or
+  // included in a bundle.
+  std::string disassembly;
+
   // Returns true if any enrichment data was produced.
   bool HasData() const {
-    return !native_symbols.empty() || !deobfuscation_data.empty();
+    return !native_symbols.empty() || !deobfuscation_data.empty() ||
+           !source_files.empty() || !disassembly.empty();
   }
 };
 
diff --git a/src/traceconv/trace_to_bundle.cc b/src/traceconv/trace_to_bundle.cc
index 6543e3c..940fea8 100644
--- a/src/traceconv/trace_to_bundle.cc
+++ b/src/traceconv/trace_to_bundle.cc
@@ -71,6 +71,9 @@
   enrich_config.symbol_paths = context.symbol_paths;
   enrich_config.no_auto_symbol_paths = context.no_auto_symbol_paths;
   enrich_config.no_auto_proguard_maps = context.no_auto_proguard_maps;
+  enrich_config.no_source_files = context.no_source_files;
+  enrich_config.source_prefix_maps = context.source_prefix_maps;
+  enrich_config.no_disassembly = context.no_disassembly;
   enrich_config.verbose = context.verbose;
   enrich_config.android_product_out = context.android_product_out;
   enrich_config.home_dir = context.home_dir;
@@ -112,6 +115,24 @@
     }
   }
 
+  // Add source files if available.
+  if (!enrich_result.source_files.empty()) {
+    auto add_status = tar.AddFile("sources.pb", enrich_result.source_files);
+    if (!add_status.ok()) {
+      return base::ErrStatus("failed to add source files to bundle: %s",
+                             add_status.c_message());
+    }
+  }
+
+  // Add disassembly if available.
+  if (!enrich_result.disassembly.empty()) {
+    auto add_status = tar.AddFile("disassembly.pb", enrich_result.disassembly);
+    if (!add_status.ok()) {
+      return base::ErrStatus("failed to add disassembly to bundle: %s",
+                             add_status.c_message());
+    }
+  }
+
   // Log any issues to stderr (without PERFETTO_LOG noise).
   if (!enrich_result.details.empty()) {
     fprintf(stderr, "%s", enrich_result.details.c_str());
diff --git a/src/traceconv/trace_to_bundle.h b/src/traceconv/trace_to_bundle.h
index f525341..eb7fa62 100644
--- a/src/traceconv/trace_to_bundle.h
+++ b/src/traceconv/trace_to_bundle.h
@@ -20,6 +20,8 @@
 #include <string>
 #include <vector>
 
+#include <utility>
+
 #include "perfetto/base/status.h"
 
 namespace perfetto::trace_to_text {
@@ -44,6 +46,16 @@
   // If true, disables automatic ProGuard/R8 mapping discovery.
   bool no_auto_proguard_maps = false;
 
+  // If true, source files referenced by symbolized frames are not bundled.
+  bool no_source_files = false;
+
+  // (from, to) prefix pairs: a source file whose path in the debug info
+  // starts with `from` is read from the same path under `to`.
+  std::vector<std::pair<std::string, std::string>> source_prefix_maps;
+
+  // If true, the disassembly of sampled functions is not bundled.
+  bool no_disassembly = false;
+
   // If true, output verbose details (all paths tried, etc.)
   bool verbose = false;