tp: add tables and stdlib for bundled source and disassembly

Groundwork for showing annotated source and disassembly for callstack
samples in the UI (#7312):

- SourceFile and ModuleDisassembly TracePacket fields carry a source
  file's contents and a module's per-function disassembly (address,
  bytes, text, static branch target, source line), keyed like
  ModuleSymbols so they can be bundled with a trace after recording.
- The trace processor parses them into the source_file,
  disassembly_function and disassembly_instruction tables, dropping
  disassembly for modules absent from the trace (recorded in the
  disassembly_invalid_mapping_id stat). The standalone symbols importer
  also recognises these packets so bundle members load after the trace.
- The callstacks.annotate stdlib module computes self and total (self
  plus callees) sample counts per callsite, per address and per source
  line, and joins a function's bundled instructions with its counts.
  The callstack forest and _callstacks_for_* macros expose mapping_id
  and rel_pc so tree nodes can be linked back to an address.
diff --git a/Android.bp b/Android.bp
index fc2c2c2..16e7649 100644
--- a/Android.bp
+++ b/Android.bp
@@ -19026,6 +19026,7 @@
 filegroup {
     name: "perfetto_src_trace_processor_perfetto_sql_stdlib_callstacks_callstacks",
     srcs: [
+        "src/trace_processor/perfetto_sql/stdlib/callstacks/annotate.sql",
         "src/trace_processor/perfetto_sql/stdlib/callstacks/stack_profile.sql",
         "src/trace_processor/perfetto_sql/stdlib/callstacks/symbolize.sql",
     ],
diff --git a/BUILD b/BUILD
index e74a405..308d466 100644
--- a/BUILD
+++ b/BUILD
@@ -4014,6 +4014,7 @@
 perfetto_filegroup(
     name = "src_trace_processor_perfetto_sql_stdlib_callstacks_callstacks",
     srcs = [
+        "src/trace_processor/perfetto_sql/stdlib/callstacks/annotate.sql",
         "src/trace_processor/perfetto_sql/stdlib/callstacks/stack_profile.sql",
         "src/trace_processor/perfetto_sql/stdlib/callstacks/symbolize.sql",
     ],
diff --git a/protos/perfetto/trace/profiling/profile_common.proto b/protos/perfetto/trace/profiling/profile_common.proto
index 331a89a..068caba 100644
--- a/protos/perfetto/trace/profiling/profile_common.proto
+++ b/protos/perfetto/trace/profiling/profile_common.proto
@@ -68,6 +68,84 @@
   repeated AddressSymbols address_symbols = 3;
 }
 
+// Contents of a source file referenced by symbolized frames.
+//
+// Emitted when a trace is bundled with symbols (`trace_processor bundle`) so
+// that source code can be shown alongside profiling data without access to
+// the original source tree.
+message SourceFile {
+  // Path of the file exactly as it appears in the debug info, i.e. the same
+  // string as Line.source_file_name and Frame.source_path_iid. This is the key
+  // used to join the contents to symbolized frames.
+  optional string path = 1;
+
+  // Raw contents of the file.
+  optional bytes contents = 2;
+}
+
+// Disassembly of functions in a module.
+//
+// Emitted when a trace is bundled with symbols (`trace_processor bundle`) for
+// functions that contain sampled addresses. Keyed by (path, build_id) in the
+// same way as ModuleSymbols.
+message ModuleDisassembly {
+  // Fully qualified path to the mapping.
+  // E.g. /system/lib64/libc.so.
+  optional string path = 1;
+
+  // .note.gnu.build-id on Linux (not hex encoded).
+  // uuid on MacOS.
+  // Module GUID on Windows.
+  optional string build_id = 2;
+
+  message Instruction {
+    // Address of the instruction relative to the start of the module. Uses the
+    // same convention as AddressSymbols.address and Frame.rel_pc.
+    optional uint64 address = 1;
+
+    // Raw encoded bytes of the instruction.
+    optional bytes bytes = 2;
+
+    // Rendered mnemonic and operands, e.g. "mov rax, qword ptr [rbp - 8]".
+    optional string text = 3;
+
+    // For direct branches and calls, the module-relative address of the target
+    // instruction. Unset for instructions which do not transfer control or
+    // whose target is not statically known.
+    optional uint64 target_address = 4;
+
+    // For branches and calls whose target lies outside this function, the
+    // name of the target symbol if known.
+    optional string target_symbol = 5;
+
+    // Index into ModuleDisassembly.source_files of the source file this
+    // instruction was generated from, together with the line number. Unset
+    // when the module has no line information.
+    optional uint32 source_file_index = 6;
+    optional uint32 line_number = 7;
+  }
+
+  message Function {
+    // Symbol name of the function.
+    optional string name = 1;
+
+    // Module-relative address of the first instruction of the function.
+    optional uint64 start_address = 2;
+
+    // Size of the function's code in bytes.
+    optional uint64 size = 3;
+
+    // Instructions in address order.
+    repeated Instruction instructions = 4;
+  }
+  repeated Function functions = 3;
+
+  // Source file paths referenced by Instruction.source_file_index. Paths use
+  // the same form as Line.source_file_name so they can be joined against
+  // SourceFile.path.
+  repeated string source_files = 4;
+}
+
 message Mapping {
   // Interning key.
   // Starts from 1, 0 is the same as "not set".
diff --git a/protos/perfetto/trace/trace_packet.proto b/protos/perfetto/trace/trace_packet.proto
index 6ef6422..4886c45 100644
--- a/protos/perfetto/trace/trace_packet.proto
+++ b/protos/perfetto/trace/trace_packet.proto
@@ -112,7 +112,7 @@
 // See the [Buffers and Dataflow](/docs/concepts/buffers.md) doc for details.
 //
 // Next reserved id: 14 (up to 15).
-// Next id: 139.
+// Next id: 141.
 message TracePacket {
   // Encapsulates the state and configuration of the ProtoVM instances running
   // when the trace was snapshotted. This allows TP to re-instantiate the VMs
@@ -236,6 +236,8 @@
     // Only used in profile packets.
     ModuleSymbols module_symbols = 61;
     DeobfuscationMapping deobfuscation_mapping = 64;
+    SourceFile source_file = 139;
+    ModuleDisassembly module_disassembly = 140;
 
     // Deprecated, use TrackDescriptor instead.
     ProcessDescriptor process_descriptor = 43;
diff --git a/src/trace_processor/importers/proto/profile_module.cc b/src/trace_processor/importers/proto/profile_module.cc
index 48a097e..cf6c679 100644
--- a/src/trace_processor/importers/proto/profile_module.cc
+++ b/src/trace_processor/importers/proto/profile_module.cc
@@ -22,6 +22,7 @@
 #include <vector>
 
 #include "perfetto/base/logging.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/ext/base/string_view.h"
 #include "perfetto/ext/base/utils.h"
 #include "perfetto/protozero/field.h"
@@ -121,6 +122,8 @@
   RegisterForField(TracePacket::kPerfSampleFieldNumber);
   RegisterForField(TracePacket::kProfilePacketFieldNumber);
   RegisterForField(TracePacket::kModuleSymbolsFieldNumber);
+  RegisterForField(TracePacket::kSourceFileFieldNumber);
+  RegisterForField(TracePacket::kModuleDisassemblyFieldNumber);
   RegisterForField(TracePacket::kSmapsPacketFieldNumber);
 }
 
@@ -149,6 +152,13 @@
     case TracePacket::kModuleSymbolsFieldNumber:
       ParseModuleSymbols(args.field.Cast<TracePacket::kModuleSymbols>());
       return;
+    case TracePacket::kSourceFileFieldNumber:
+      ParseSourceFile(args.field.Cast<TracePacket::kSourceFile>());
+      return;
+    case TracePacket::kModuleDisassemblyFieldNumber:
+      ParseModuleDisassembly(
+          args.field.Cast<TracePacket::kModuleDisassembly>());
+      return;
     case TracePacket::kSmapsPacketFieldNumber:
       ParseSmapsPacket(args.ts, args.field.Cast<TracePacket::kSmapsPacket>());
       return;
@@ -639,6 +649,86 @@
   }
 }
 
+void ProfileModule::ParseSourceFile(ConstBytes blob) {
+  protos::pbzero::SourceFile::Decoder file(blob.data, blob.size);
+  ConstBytes contents = file.contents();
+  context_->storage->mutable_source_file_table()->Insert(
+      {context_->storage->InternString(file.path()),
+       context_->storage->InternString(base::StringView(
+           reinterpret_cast<const char*>(contents.data), contents.size))});
+}
+
+void ProfileModule::ParseModuleDisassembly(ConstBytes blob) {
+  protos::pbzero::ModuleDisassembly::Decoder disassembly(blob.data, blob.size);
+  std::optional<BuildId> build_id;
+  if (disassembly.build_id().size > 0) {
+    build_id = BuildId::FromRaw(disassembly.build_id());
+  }
+
+  // Only keep disassembly for modules which appear in the trace: without a
+  // mapping there is no rel_pc space to attribute samples in.
+  auto mappings =
+      context_->mapping_tracker->FindMappings(disassembly.path(), build_id);
+  if (mappings.empty()) {
+    context_->stats_tracker->IncrementStats(
+        stats::disassembly_invalid_mapping_id);
+    return;
+  }
+
+  StringId path_id = context_->storage->InternString(disassembly.path());
+  std::optional<StringId> build_id_id;
+  if (build_id) {
+    build_id_id =
+        context_->storage->InternString(base::StringView(build_id->ToHex()));
+  }
+
+  std::vector<StringId> source_files;
+  for (auto it = disassembly.source_files(); it; ++it) {
+    source_files.push_back(context_->storage->InternString(*it));
+  }
+
+  auto* functions = context_->storage->mutable_disassembly_function_table();
+  auto* instructions =
+      context_->storage->mutable_disassembly_instruction_table();
+  for (auto fn_it = disassembly.functions(); fn_it; ++fn_it) {
+    protos::pbzero::ModuleDisassembly::Function::Decoder function(*fn_it);
+    auto function_id =
+        functions
+            ->Insert({path_id, build_id_id,
+                      context_->storage->InternString(function.name()),
+                      static_cast<int64_t>(function.start_address()),
+                      static_cast<int64_t>(function.size())})
+            .id;
+    for (auto insn_it = function.instructions(); insn_it; ++insn_it) {
+      protos::pbzero::ModuleDisassembly::Instruction::Decoder insn(*insn_it);
+      ConstBytes bytes = insn.bytes();
+      std::optional<int64_t> target_rel_pc;
+      if (insn.has_target_address()) {
+        target_rel_pc = static_cast<int64_t>(insn.target_address());
+      }
+      std::optional<StringId> target_symbol;
+      if (insn.has_target_symbol()) {
+        target_symbol = context_->storage->InternString(insn.target_symbol());
+      }
+      std::optional<StringId> source_file;
+      if (insn.has_source_file_index() &&
+          insn.source_file_index() < source_files.size()) {
+        source_file = source_files[insn.source_file_index()];
+      }
+      std::optional<uint32_t> line_number;
+      if (source_file && insn.has_line_number()) {
+        line_number = insn.line_number();
+      }
+      instructions->Insert(
+          {function_id, static_cast<int64_t>(insn.address()),
+           context_->storage->InternString(base::StringView(base::ToHex(
+               reinterpret_cast<const char*>(bytes.data), bytes.size))),
+           context_->storage->InternString(insn.text()), target_rel_pc,
+           target_symbol, source_file, line_number});
+    }
+  }
+}
+
 void ProfileModule::ParseSmapsPacket(int64_t ts, ConstBytes blob) {
   protos::pbzero::SmapsPacket::Decoder sp(blob.data, blob.size);
   auto upid = context_->process_tracker->GetOrCreateProcess(sp.pid());
diff --git a/src/trace_processor/importers/proto/profile_module.h b/src/trace_processor/importers/proto/profile_module.h
index c6bac5e..33bc151 100644
--- a/src/trace_processor/importers/proto/profile_module.h
+++ b/src/trace_processor/importers/proto/profile_module.h
@@ -69,6 +69,11 @@
                           PacketSequenceStateGeneration*,
                           protozero::ConstBytes);
   void ParseModuleSymbols(protozero::ConstBytes);
+
+  // bundled source and disassembly:
+  void ParseSourceFile(protozero::ConstBytes);
+  void ParseModuleDisassembly(protozero::ConstBytes);
+
   void ParseSmapsPacket(int64_t ts, protozero::ConstBytes);
   void ParsePackedSmaps(int64_t ts, UniquePid upid, protozero::ConstBytes);
 
diff --git a/src/trace_processor/importers/proto/proto_trace_reader.cc b/src/trace_processor/importers/proto/proto_trace_reader.cc
index 4d5f798..e007018 100644
--- a/src/trace_processor/importers/proto/proto_trace_reader.cc
+++ b/src/trace_processor/importers/proto/proto_trace_reader.cc
@@ -1355,7 +1355,16 @@
 constexpr uint16_t kModuleSymbolsTag =
     protozero::proto_utils::MakeTagLengthDelimited(
         protos::pbzero::TracePacket::kModuleSymbolsFieldNumber);
+constexpr uint16_t kSourceFileTag =
+    protozero::proto_utils::MakeTagLengthDelimited(
+        protos::pbzero::TracePacket::kSourceFileFieldNumber);
+constexpr uint16_t kModuleDisassemblyTag =
+    protozero::proto_utils::MakeTagLengthDelimited(
+        protos::pbzero::TracePacket::kModuleDisassemblyFieldNumber);
 
+// Whether the first packet of the trace carries data that is bundled with a
+// trace after recording (symbols, source files, disassembly). Such packets
+// back-patch existing rows, so they must be parsed after the main trace.
 bool IsProtoTraceWithSymbols(const uint8_t* ptr, size_t size) {
   const uint8_t* const end = ptr + size;
 
@@ -1382,7 +1391,8 @@
     return false;
   }
 
-  return tag == kModuleSymbolsTag;
+  return tag == kModuleSymbolsTag || tag == kSourceFileTag ||
+         tag == kModuleDisassemblyTag;
 }
 
 // Perfetto proto trace.
diff --git a/src/trace_processor/perfetto_sql/stdlib/appleos/instruments/samples.sql b/src/trace_processor/perfetto_sql/stdlib/appleos/instruments/samples.sql
index bd1ad69..f12bbde 100644
--- a/src/trace_processor/perfetto_sql/stdlib/appleos/instruments/samples.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/appleos/instruments/samples.sql
@@ -62,7 +62,15 @@
   cumulative_count LONG
 )
 AS
-SELECT r.*, a.cumulative_count
+SELECT
+  r.id,
+  r.parent_id,
+  r.name,
+  r.mapping_name,
+  r.source_file,
+  r.line_number,
+  r.self_count,
+  a.cumulative_count
 FROM _callstacks_self_to_cumulative!((
   SELECT id, parent_id, self_count
   FROM _appleos_instruments_raw_callstacks
diff --git a/src/trace_processor/perfetto_sql/stdlib/callstacks/BUILD.gn b/src/trace_processor/perfetto_sql/stdlib/callstacks/BUILD.gn
index 797e02a..277afc6 100644
--- a/src/trace_processor/perfetto_sql/stdlib/callstacks/BUILD.gn
+++ b/src/trace_processor/perfetto_sql/stdlib/callstacks/BUILD.gn
@@ -16,6 +16,7 @@
 
 perfetto_sql_source_set("callstacks") {
   sources = [
+    "annotate.sql",
     "stack_profile.sql",
     "symbolize.sql",
   ]
diff --git a/src/trace_processor/perfetto_sql/stdlib/callstacks/annotate.sql b/src/trace_processor/perfetto_sql/stdlib/callstacks/annotate.sql
new file mode 100644
index 0000000..233f1cc
--- /dev/null
+++ b/src/trace_processor/perfetto_sql/stdlib/callstacks/annotate.sql
@@ -0,0 +1,193 @@
+--
+-- Copyright 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
+--
+--     https://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.
+
+-- Sample counts for every callsite on the stack of any of the given samples.
+--
+-- `self_count` is the number of samples whose leaf callsite is the row's
+-- callsite. `total_count` additionally includes samples whose stack passes
+-- through the callsite, i.e. samples taken in one of its callees.
+CREATE PERFETTO MACRO _callsite_sample_counts(
+  -- Subquery with a `callsite_id` column and one row per sample.
+  samples TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  WITH RECURSIVE
+    metrics AS MATERIALIZED (
+      SELECT
+        callsite_id,
+        count() AS self_count
+      FROM $samples
+      WHERE
+        callsite_id IS NOT NULL
+      GROUP BY
+        callsite_id
+    ),
+    -- Every (ancestor or self, sampled callsite) pair.
+    ancestors(callsite_id, sample_callsite_id) AS (
+      SELECT
+        callsite_id,
+        callsite_id
+      FROM metrics
+      UNION ALL
+      SELECT
+        c.parent_id,
+        a.sample_callsite_id
+      FROM ancestors AS a
+      JOIN stack_profile_callsite AS c
+        ON c.id = a.callsite_id
+      WHERE
+        c.parent_id IS NOT NULL
+    )
+  SELECT
+    a.callsite_id,
+    sum(iif(a.callsite_id = m.callsite_id, m.self_count, 0)) AS self_count,
+    sum(m.self_count) AS total_count
+  FROM ancestors AS a
+  JOIN metrics AS m
+    ON m.callsite_id = a.sample_callsite_id
+  GROUP BY
+    a.callsite_id
+);
+
+-- Sample counts per instruction address.
+--
+-- For the leaf frame of a sample the address is the sampled program counter.
+-- For every other frame it is the return address of the call the frame was
+-- executing, so `total_count` on a call instruction includes the samples
+-- taken inside the callee.
+CREATE PERFETTO MACRO _sample_counts_by_address(
+  -- Subquery with a `callsite_id` column and one row per sample.
+  samples TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT
+    f.mapping AS mapping_id,
+    m.name AS mapping_name,
+    m.build_id,
+    f.rel_pc,
+    sum(c.self_count) AS self_count,
+    sum(c.total_count) AS total_count
+  FROM _callsite_sample_counts!($samples) AS c
+  JOIN stack_profile_callsite AS sc
+    ON sc.id = c.callsite_id
+  JOIN stack_profile_frame AS f
+    ON f.id = sc.frame_id
+  JOIN stack_profile_mapping AS m
+    ON m.id = f.mapping
+  GROUP BY
+    f.mapping,
+    f.rel_pc
+);
+
+-- Sample counts per source line, attributed through the innermost symbol of
+-- each sampled frame. Frames without line information are not included.
+CREATE PERFETTO MACRO _sample_counts_by_source_line(
+  -- Subquery with a `callsite_id` column and one row per sample.
+  samples TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT
+    s.source_file,
+    s.line_number,
+    sum(c.self_count) AS self_count,
+    sum(c.total_count) AS total_count
+  FROM _callsite_sample_counts!($samples) AS c
+  JOIN stack_profile_callsite AS sc
+    ON sc.id = c.callsite_id
+  JOIN stack_profile_frame AS f
+    ON f.id = sc.frame_id
+  -- The innermost inlined symbol of a frame is the one whose id equals the
+  -- frame's symbol_set_id.
+  JOIN stack_profile_symbol AS s
+    ON s.id = f.symbol_set_id
+  WHERE
+    s.source_file IS NOT NULL AND s.line_number IS NOT NULL
+  GROUP BY
+    s.source_file,
+    s.line_number
+);
+
+-- The bundled disassembly function containing an address in a mapping, or
+-- NULL if no disassembly was bundled for it.
+CREATE PERFETTO FUNCTION _disassembly_function_for_address(
+  -- Id of the stack_profile_mapping the address belongs to.
+  mapping_id LONG,
+  -- Address relative to the start of the mapping.
+  rel_pc LONG
+)
+-- Id in disassembly_function.
+RETURNS LONG
+AS
+SELECT df.id
+FROM disassembly_function AS df
+JOIN stack_profile_mapping AS m
+  ON m.id = $mapping_id
+WHERE
+  iif(df.build_id IS NOT NULL, df.build_id = m.build_id, df.path = m.name)
+  AND $rel_pc >= df.start_rel_pc
+  AND $rel_pc < df.start_rel_pc + df.size
+LIMIT 1;
+
+-- The bundled disassembly of a function, in address order, with the sample
+-- counts of each instruction.
+CREATE PERFETTO MACRO _annotated_disassembly(
+  -- Subquery with a `callsite_id` column and one row per sample.
+  samples TableOrSubquery,
+  -- Id in disassembly_function.
+  function_id Expr
+)
+RETURNS TableOrSubquery
+AS (
+  WITH
+    fn AS (
+      SELECT
+        *
+      FROM disassembly_function
+      WHERE
+        id = $function_id
+    ),
+    counts AS (
+      SELECT
+        a.rel_pc,
+        sum(a.self_count) AS self_count,
+        sum(a.total_count) AS total_count
+      FROM _sample_counts_by_address!($samples) AS a
+      JOIN fn
+        ON iif(fn.build_id IS NOT NULL, a.build_id = fn.build_id, a.mapping_name = fn.path)
+      GROUP BY
+        a.rel_pc
+    )
+  SELECT
+    i.id AS instruction_id,
+    i.rel_pc,
+    i.bytes,
+    i.text,
+    i.target_rel_pc,
+    i.target_symbol,
+    i.source_file,
+    i.line_number,
+    coalesce(c.self_count, 0) AS self_count,
+    coalesce(c.total_count, 0) AS total_count
+  FROM disassembly_instruction AS i
+  LEFT JOIN counts AS c
+    USING (rel_pc)
+  WHERE
+    i.function_id = $function_id
+  ORDER BY
+    i.rel_pc
+);
diff --git a/src/trace_processor/perfetto_sql/stdlib/callstacks/stack_profile.sql b/src/trace_processor/perfetto_sql/stdlib/callstacks/stack_profile.sql
index de6746c..3e4bde9 100644
--- a/src/trace_processor/perfetto_sql/stdlib/callstacks/stack_profile.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/callstacks/stack_profile.sql
@@ -85,6 +85,7 @@
     coalesce(s.name, f.deobfuscated_name, f.name, 'unknown')
   ) AS name,
   f.mapping AS mapping_id,
+  f.rel_pc,
   s.source_file,
   coalesce(jsf.line, s.line_number) AS line_number,
   coalesce(jsf.col, 0) AS column_number,
@@ -132,6 +133,8 @@
     f.callsite_id,
     f.name,
     m.name AS mapping_name,
+    f.mapping_id,
+    f.rel_pc,
     f.source_file,
     f.line_number,
     f.inlined,
@@ -168,6 +171,8 @@
     c.parent_id,
     c.name,
     c.mapping_name,
+    c.mapping_id,
+    c.rel_pc,
     c.source_file,
     c.line_number,
     iif(c.is_leaf_function_in_callsite_frame, coalesce(m.self_count, 0), 0) AS self_count
@@ -199,6 +204,8 @@
     c.parent_id,
     c.name,
     c.mapping_name,
+    c.mapping_id,
+    c.rel_pc,
     c.source_file,
     c.line_number,
     iif(c.is_leaf_function_in_callsite_frame, coalesce(m.self_value, 0), 0) AS self_value
diff --git a/src/trace_processor/perfetto_sql/stdlib/linux/perf/samples.sql b/src/trace_processor/perfetto_sql/stdlib/linux/perf/samples.sql
index e8e3d9e..2ba467e 100644
--- a/src/trace_processor/perfetto_sql/stdlib/linux/perf/samples.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/linux/perf/samples.sql
@@ -59,7 +59,15 @@
   cumulative_count LONG
 )
 AS
-SELECT r.*, a.cumulative_count
+SELECT
+  r.id,
+  r.parent_id,
+  r.name,
+  r.mapping_name,
+  r.source_file,
+  r.line_number,
+  r.self_count,
+  a.cumulative_count
 FROM _callstacks_self_to_cumulative!((
   SELECT id, parent_id, self_count
   FROM _linux_perf_raw_callstacks
diff --git a/src/trace_processor/perfetto_sql/stdlib/prelude/after_eof/views.sql b/src/trace_processor/perfetto_sql/stdlib/prelude/after_eof/views.sql
index 21fc526..f905ba1 100644
--- a/src/trace_processor/perfetto_sql/stdlib/prelude/after_eof/views.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/prelude/after_eof/views.sql
@@ -666,6 +666,69 @@
 AS
 SELECT * FROM __intrinsic_stack_profile_symbol;
 
+-- Contents of source files bundled with the trace, for the files referenced
+-- by symbolized frames.
+CREATE PERFETTO VIEW source_file(
+  -- The id of the row.
+  id ID,
+  -- Path of the file as it appears in the debug info. Joins to
+  -- stack_profile_symbol.source_file.
+  path STRING,
+  -- Raw contents of the file.
+  contents STRING
+)
+AS
+SELECT * FROM __intrinsic_source_file;
+
+-- Functions for which disassembly is bundled with the trace.
+CREATE PERFETTO VIEW disassembly_function(
+  -- The id of the row.
+  id ID,
+  -- Path of the module containing the function, matching
+  -- stack_profile_mapping.name.
+  path STRING,
+  -- Hex-encoded build id of the module, matching
+  -- stack_profile_mapping.build_id.
+  build_id STRING,
+  -- Symbol name of the function.
+  name STRING,
+  -- Address of the first instruction relative to the start of the module, in
+  -- the same space as stack_profile_frame.rel_pc.
+  start_rel_pc LONG,
+  -- Size of the function's code in bytes.
+  size LONG
+)
+AS
+SELECT * FROM __intrinsic_disassembly_function;
+
+-- Instructions of functions in disassembly_function, in address order.
+CREATE PERFETTO VIEW disassembly_instruction(
+  -- The id of the row.
+  id ID,
+  -- The function this instruction belongs to.
+  function_id JOINID(disassembly_function.id),
+  -- Address of the instruction relative to the start of the module, in the
+  -- same space as stack_profile_frame.rel_pc.
+  rel_pc LONG,
+  -- Hex-encoded raw bytes of the instruction.
+  bytes STRING,
+  -- Rendered mnemonic and operands.
+  text STRING,
+  -- For direct branches and calls, the module-relative address of the target
+  -- instruction.
+  target_rel_pc LONG,
+  -- For branches and calls out of the function, the name of the target symbol
+  -- if known.
+  target_symbol STRING,
+  -- Path of the source file the instruction was generated from. Joins to
+  -- source_file.path.
+  source_file STRING,
+  -- Line in source_file the instruction was generated from.
+  line_number LONG
+)
+AS
+SELECT * FROM __intrinsic_disassembly_instruction;
+
 -- Allocations that happened at a callsite.
 CREATE PERFETTO VIEW heap_profile_allocation(
   -- The id of the row.
diff --git a/src/trace_processor/plugins/storage_tables/storage_tables.cc b/src/trace_processor/plugins/storage_tables/storage_tables.cc
index 4137548..541b71a 100644
--- a/src/trace_processor/plugins/storage_tables/storage_tables.cc
+++ b/src/trace_processor/plugins/storage_tables/storage_tables.cc
@@ -159,6 +159,9 @@
     AddDataframe(out, s->mutable_v8_wasm_code_table());
     AddDataframe(out, s->mutable_v8_regexp_code_table());
     AddDataframe(out, s->mutable_symbol_table());
+    AddDataframe(out, s->mutable_source_file_table());
+    AddDataframe(out, s->mutable_disassembly_function_table());
+    AddDataframe(out, s->mutable_disassembly_instruction_table());
     AddDataframe(out, s->mutable_jit_code_table());
     AddDataframe(out, s->mutable_jit_frame_table());
     AddDataframe(out, s->mutable_android_key_events_table());
diff --git a/src/trace_processor/storage/stats.h b/src/trace_processor/storage/stats.h
index 097a9da..1d1f27d 100644
--- a/src/trace_processor/storage/stats.h
+++ b/src/trace_processor/storage/stats.h
@@ -234,6 +234,9 @@
   F(stackprofile_invalid_mapping_id,      kSingle,  kError,    kTrace, Scope::kMachineAndTrace,    ""), \
   F(stackprofile_invalid_frame_id,        kSingle,  kError,    kTrace, Scope::kMachineAndTrace,    ""), \
   F(stackprofile_invalid_callstack_id,    kSingle,  kError,    kTrace, Scope::kMachineAndTrace,    ""), \
+  F(disassembly_invalid_mapping_id,       kSingle,  kError,    kTrace, Scope::kMachineAndTrace,          \
+      "ModuleDisassembly packet referenced a module with no mapping in the "   \
+      "trace. Ignored"),                                                       \
   F(stackprofile_parser_error,            kSingle,  kError,    kTrace, Scope::kMachineAndTrace,    ""), \
   F(smaps_parser_errors,                  kSingle,  kError,    kTrace, Scope::kMachineAndTrace,         \
       "Count of malformed PackedSmaps packets. Data in smaps tables unreliable."),                      \
diff --git a/src/trace_processor/storage/trace_storage.h b/src/trace_processor/storage/trace_storage.h
index a12d987..6d9bf38 100644
--- a/src/trace_processor/storage/trace_storage.h
+++ b/src/trace_processor/storage/trace_storage.h
@@ -647,6 +647,28 @@
     return mutable_table<tables::SymbolTable>();
   }
 
+  const tables::SourceFileTable& source_file_table() const {
+    return table<tables::SourceFileTable>();
+  }
+  tables::SourceFileTable* mutable_source_file_table() {
+    return mutable_table<tables::SourceFileTable>();
+  }
+
+  const tables::DisassemblyFunctionTable& disassembly_function_table() const {
+    return table<tables::DisassemblyFunctionTable>();
+  }
+  tables::DisassemblyFunctionTable* mutable_disassembly_function_table() {
+    return mutable_table<tables::DisassemblyFunctionTable>();
+  }
+
+  const tables::DisassemblyInstructionTable& disassembly_instruction_table()
+      const {
+    return table<tables::DisassemblyInstructionTable>();
+  }
+  tables::DisassemblyInstructionTable* mutable_disassembly_instruction_table() {
+    return mutable_table<tables::DisassemblyInstructionTable>();
+  }
+
   const tables::HeapGraphObjectTable& heap_graph_object_table() const {
     return table<tables::HeapGraphObjectTable>();
   }
diff --git a/src/trace_processor/tables/profiler_tables.py b/src/trace_processor/tables/profiler_tables.py
index cf8542a..6f9a090 100644
--- a/src/trace_processor/tables/profiler_tables.py
+++ b/src/trace_processor/tables/profiler_tables.py
@@ -892,6 +892,126 @@
                 ''''''
         }))
 
+SOURCE_FILE_TABLE = Table(
+    python_module=__file__,
+    class_name='SourceFileTable',
+    sql_name='__intrinsic_source_file',
+    wrapping_sql_view=WrappingSqlView('source_file'),
+    columns=[
+        C('path', CppString()),
+        C('contents', CppString()),
+    ],
+    tabledoc=TableDoc(
+        doc='''
+            Contents of source files bundled with the trace. Populated from
+            SourceFile packets, which `trace_processor bundle` emits for the
+            files referenced by symbolized frames.
+        ''',
+        group='Callstack profilers',
+        columns={
+            'path':
+                '''
+                    Path of the file as it appears in the debug info. Joins to
+                    stack_profile_symbol.source_file.
+                ''',
+            'contents':
+                '''Raw contents of the file.''',
+        }))
+
+DISASSEMBLY_FUNCTION_TABLE = Table(
+    python_module=__file__,
+    class_name='DisassemblyFunctionTable',
+    sql_name='__intrinsic_disassembly_function',
+    wrapping_sql_view=WrappingSqlView('disassembly_function'),
+    columns=[
+        C('path', CppString()),
+        C('build_id', CppOptional(CppString())),
+        C('name', CppString()),
+        C('start_rel_pc', CppInt64()),
+        C('size', CppInt64()),
+    ],
+    tabledoc=TableDoc(
+        doc='''
+            Functions for which disassembly is bundled with the trace.
+            Populated from ModuleDisassembly packets, which
+            `trace_processor bundle` emits for functions containing sampled
+            addresses.
+        ''',
+        group='Callstack profilers',
+        columns={
+            'path':
+                '''
+                    Path of the module containing the function, matching
+                    stack_profile_mapping.name.
+                ''',
+            'build_id':
+                '''
+                    Hex-encoded build id of the module, matching
+                    stack_profile_mapping.build_id.
+                ''',
+            'name':
+                '''Symbol name of the function.''',
+            'start_rel_pc':
+                '''
+                    Address of the first instruction relative to the start of
+                    the module, in the same space as stack_profile_frame.rel_pc.
+                ''',
+            'size':
+                '''Size of the function's code in bytes.''',
+        }))
+
+DISASSEMBLY_INSTRUCTION_TABLE = Table(
+    python_module=__file__,
+    class_name='DisassemblyInstructionTable',
+    sql_name='__intrinsic_disassembly_instruction',
+    wrapping_sql_view=WrappingSqlView('disassembly_instruction'),
+    columns=[
+        C('function_id', CppTableId(DISASSEMBLY_FUNCTION_TABLE)),
+        C('rel_pc', CppInt64()),
+        C('bytes', CppString()),
+        C('text', CppString()),
+        C('target_rel_pc', CppOptional(CppInt64())),
+        C('target_symbol', CppOptional(CppString())),
+        C('source_file', CppOptional(CppString())),
+        C('line_number', CppOptional(CppUint32())),
+    ],
+    tabledoc=TableDoc(
+        doc='''
+            Instructions of functions in disassembly_function, in address
+            order.
+        ''',
+        group='Callstack profilers',
+        columns={
+            'function_id':
+                '''The function this instruction belongs to.''',
+            'rel_pc':
+                '''
+                    Address of the instruction relative to the start of the
+                    module, in the same space as stack_profile_frame.rel_pc.
+                ''',
+            'bytes':
+                '''Hex-encoded raw bytes of the instruction.''',
+            'text':
+                '''Rendered mnemonic and operands.''',
+            'target_rel_pc':
+                '''
+                    For direct branches and calls, the module-relative address
+                    of the target instruction.
+                ''',
+            'target_symbol':
+                '''
+                    For branches and calls out of the function, the name of the
+                    target symbol if known.
+                ''',
+            'source_file':
+                '''
+                    Path of the source file the instruction was generated from.
+                    Joins to source_file.path.
+                ''',
+            'line_number':
+                '''Line in source_file the instruction was generated from.''',
+        }))
+
 HEAP_PROFILE_TABLE = Table(
     python_module=__file__,
     class_name='HeapProfileTable',
@@ -1655,6 +1775,8 @@
     AGGREGATE_PROFILE_TABLE,
     AGGREGATE_SAMPLE_TABLE,
     CHROME_STACK_SAMPLE_EXTRAS_TABLE,
+    DISASSEMBLY_FUNCTION_TABLE,
+    DISASSEMBLY_INSTRUCTION_TABLE,
     EXPERIMENTAL_FLAMEGRAPH_TABLE,
     GPU_CONTEXT_TABLE,
     GPU_COUNTER_GROUP_TABLE,
@@ -1676,6 +1798,7 @@
     PROFILER_SESSION_TABLE,
     PROFILER_SMAPS_TABLE,
     PROFILER_TASK_CONTEXT_TABLE,
+    SOURCE_FILE_TABLE,
     STACK_PROFILE_CALLSITE_TABLE,
     STACK_PROFILE_FRAME_TABLE,
     STACK_PROFILE_MAPPING_TABLE,
diff --git a/test/trace_processor/diff_tests/include_index.py b/test/trace_processor/diff_tests/include_index.py
index 3ffb076..b4f318d 100644
--- a/test/trace_processor/diff_tests/include_index.py
+++ b/test/trace_processor/diff_tests/include_index.py
@@ -117,6 +117,7 @@
 from diff_tests.parser.profiling.tests_heap_graph import ProfilingHeapGraph
 from diff_tests.parser.profiling.tests_heap_profiling import ProfilingHeapProfiling
 from diff_tests.parser.profiling.tests_llvm_symbolizer import ProfilingLlvmSymbolizer
+from diff_tests.parser.profiling.tests_source_and_disassembly import ProfilingSourceAndDisassembly
 from diff_tests.parser.sched.tests import SchedParser
 from diff_tests.parser.simpleperf.tests import Simpleperf
 from diff_tests.parser.simpleperf_proto.tests import SimpleperfProtoParser
@@ -179,6 +180,7 @@
 from diff_tests.stdlib.span_join.tests_outer_join import SpanJoinOuterJoin
 from diff_tests.stdlib.span_join.tests_regression import SpanJoinRegression
 from diff_tests.stdlib.span_join.tests_smoke import SpanJoinSmoke
+from diff_tests.stdlib.callstacks.tests import Callstacks
 from diff_tests.stdlib.stacks.tests import Stacks
 from diff_tests.stdlib.symbolize.tests import Symbolize
 from diff_tests.stdlib.tests import StdlibSmoke
@@ -250,6 +252,7 @@
       ProfilingHeapGraph,
       ProfilingHeapProfiling,
       ProfilingLlvmSymbolizer,
+      ProfilingSourceAndDisassembly,
       SchedParser,
       Simpleperf,
       SimpleperfProtoParser,
@@ -363,6 +366,7 @@
       SpanJoinOuterJoin,
       SpanJoinRegression,
       SpanJoinSmoke,
+      Callstacks,
       Stacks,
       CreateIntervals,
       IntervalsFillGaps,
diff --git a/test/trace_processor/diff_tests/parser/profiling/source_and_disassembly.textproto b/test/trace_processor/diff_tests/parser/profiling/source_and_disassembly.textproto
new file mode 100644
index 0000000..3a5f245
--- /dev/null
+++ b/test/trace_processor/diff_tests/parser/profiling/source_and_disassembly.textproto
@@ -0,0 +1,156 @@
+packet {
+  process_tree {
+    processes {
+      pid: 1
+      ppid: 0
+      cmdline: "init"
+      uid: 0
+    }
+    processes {
+      pid: 2
+      ppid: 1
+      cmdline: "system_server"
+      uid: 1000
+    }
+  }
+}
+
+packet {
+  clock_snapshot {
+    clocks: {
+      clock_id: 6 # BOOTTIME
+      timestamp: 0
+    }
+    clocks: {
+      clock_id: 4 # MONOTONIC_COARSE
+      timestamp: 10
+    }
+  }
+}
+
+packet {
+  trusted_packet_sequence_id: 999
+  previous_packet_dropped: 1
+  incremental_state_cleared: true
+  timestamp: 10
+  profile_packet {
+    strings {
+      iid: 1
+      str: "f1"
+    }
+    strings {
+      iid: 2
+      str: "f2"
+    }
+    strings {
+      iid: 3
+      str: "f3"
+    }
+    strings {
+      iid: 4
+      str: "liblib.so"
+    }
+    strings {
+      iid: 5
+      str: "build-id"
+    }
+    frames {
+      iid: 1
+      function_name_id: 1
+      mapping_id: 1
+      rel_pc: 0x1000
+    }
+    frames {
+      iid: 2
+      function_name_id: 2
+      mapping_id: 1
+      rel_pc: 0x2001
+    }
+    frames {
+      iid: 3
+      function_name_id: 3
+      mapping_id: 1
+      rel_pc: 0x3000
+    }
+    callstacks {
+      iid: 1
+      frame_ids: 1
+      frame_ids: 2
+      frame_ids: 3
+    }
+    mappings {
+      iid: 1
+      path_string_ids: 4
+      build_id: 5
+    }
+    process_dumps {
+      pid: 2
+      samples {
+        callstack_id: 1
+        self_allocated: 2000
+        self_freed: 1000
+        alloc_count: 2
+        free_count: 1
+      }
+    }
+  }
+}
+
+# Bundled source and disassembly packets.
+packet {
+  source_file {
+    path: "f2.cc"
+    contents: "int f2() {\n  return 2;\n}\n"
+  }
+}
+packet {
+  module_disassembly {
+    path: "/liblib.so"
+    build_id: "build-id"
+    source_files: "f2.cc"
+    functions {
+      name: "f2"
+      start_address: 0x2000
+      size: 8
+      instructions {
+        address: 0x2000
+        bytes: "\x55"
+        text: "push rbp"
+        source_file_index: 0
+        line_number: 1
+      }
+      instructions {
+        address: 0x2001
+        bytes: "\xeb\xfd"
+        text: "jmp 0x2000"
+        target_address: 0x2000
+        source_file_index: 0
+        line_number: 2
+      }
+      instructions {
+        address: 0x2003
+        bytes: "\xe8\x00\x00\x00\x00"
+        text: "call 0x3000"
+        target_address: 0x3000
+        target_symbol: "f3"
+      }
+    }
+  }
+}
+# Disassembly for a module which does not appear in the trace is dropped.
+packet {
+  module_disassembly {
+    path: "/libmissing.so"
+    build_id: "missing"
+    functions {
+      name: "missing"
+      start_address: 0x10
+      size: 1
+      instructions {
+        address: 0x10
+        bytes: "\xc3"
+        text: "ret"
+      }
+    }
+  }
+}
diff --git a/test/trace_processor/diff_tests/parser/profiling/tests_source_and_disassembly.py b/test/trace_processor/diff_tests/parser/profiling/tests_source_and_disassembly.py
new file mode 100644
index 0000000..e900fcf
--- /dev/null
+++ b/test/trace_processor/diff_tests/parser/profiling/tests_source_and_disassembly.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+# 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.
+
+from python.generators.diff_tests.testing import Path
+from python.generators.diff_tests.testing import Csv
+from python.generators.diff_tests.testing import DiffTestBlueprint
+from python.generators.diff_tests.testing import TestSuite
+
+
+class ProfilingSourceAndDisassembly(TestSuite):
+
+  def test_source_file(self):
+    return DiffTestBlueprint(
+        trace=Path('source_and_disassembly.textproto'),
+        query="""
+        SELECT path, contents FROM source_file;
+        """,
+        out=Csv("""
+        "path","contents"
+        "f2.cc","int f2() {
+          return 2;
+        }
+        "
+        """))
+
+  def test_disassembly_function(self):
+    return DiffTestBlueprint(
+        trace=Path('source_and_disassembly.textproto'),
+        query="""
+        SELECT path, build_id, name, start_rel_pc, size
+        FROM disassembly_function;
+        """,
+        out=Csv("""
+        "path","build_id","name","start_rel_pc","size"
+        "/liblib.so","6275696c642d6964","f2",8192,8
+        """))
+
+  def test_disassembly_instruction(self):
+    return DiffTestBlueprint(
+        trace=Path('source_and_disassembly.textproto'),
+        query="""
+        SELECT
+          f.name AS function_name,
+          i.rel_pc,
+          i.bytes,
+          i.text,
+          i.target_rel_pc,
+          i.target_symbol,
+          i.source_file,
+          i.line_number
+        FROM disassembly_instruction i
+        JOIN disassembly_function f ON i.function_id = f.id
+        ORDER BY i.rel_pc;
+        """,
+        out=Csv("""
+        "function_name","rel_pc","bytes","text","target_rel_pc","target_symbol","source_file","line_number"
+        "f2",8192,"55","push rbp","[NULL]","[NULL]","f2.cc",1
+        "f2",8193,"ebfd","jmp 0x2000",8192,"[NULL]","f2.cc",2
+        "f2",8195,"e800000000","call 0x3000",12288,"f3","[NULL]","[NULL]"
+        """))
+
+  def test_disassembly_unknown_module_is_dropped(self):
+    return DiffTestBlueprint(
+        trace=Path('source_and_disassembly.textproto'),
+        query="""
+        SELECT value FROM stats WHERE name = 'disassembly_invalid_mapping_id';
+        """,
+        out=Csv("""
+        "value"
+        1
+        """))
diff --git a/test/trace_processor/diff_tests/stdlib/callstacks/annotate.textproto b/test/trace_processor/diff_tests/stdlib/callstacks/annotate.textproto
new file mode 100644
index 0000000..5de7718
--- /dev/null
+++ b/test/trace_processor/diff_tests/stdlib/callstacks/annotate.textproto
@@ -0,0 +1,178 @@
+packet {
+  process_tree {
+    processes {
+      pid: 1
+      ppid: 0
+      cmdline: "init"
+      uid: 0
+    }
+    processes {
+      pid: 2
+      ppid: 1
+      cmdline: "system_server"
+      uid: 1000
+    }
+  }
+}
+
+packet {
+  clock_snapshot {
+    clocks: {
+      clock_id: 6 # BOOTTIME
+      timestamp: 0
+    }
+    clocks: {
+      clock_id: 4 # MONOTONIC_COARSE
+      timestamp: 10
+    }
+  }
+}
+
+# Two stacks sharing a prefix: [f1, f2, f3] and [f1, f2], one sample each.
+packet {
+  trusted_packet_sequence_id: 999
+  previous_packet_dropped: 1
+  incremental_state_cleared: true
+  timestamp: 10
+  profile_packet {
+    strings {
+      iid: 1
+      str: "f1"
+    }
+    strings {
+      iid: 2
+      str: "f2"
+    }
+    strings {
+      iid: 3
+      str: "f3"
+    }
+    strings {
+      iid: 4
+      str: "liblib.so"
+    }
+    strings {
+      iid: 5
+      str: "build-id"
+    }
+    frames {
+      iid: 1
+      function_name_id: 1
+      mapping_id: 1
+      rel_pc: 0x1000
+    }
+    frames {
+      iid: 2
+      function_name_id: 2
+      mapping_id: 1
+      rel_pc: 0x2001
+    }
+    frames {
+      iid: 3
+      function_name_id: 3
+      mapping_id: 1
+      rel_pc: 0x3000
+    }
+    callstacks {
+      iid: 1
+      frame_ids: 1
+      frame_ids: 2
+      frame_ids: 3
+    }
+    callstacks {
+      iid: 2
+      frame_ids: 1
+      frame_ids: 2
+    }
+    mappings {
+      iid: 1
+      path_string_ids: 4
+      build_id: 5
+    }
+    process_dumps {
+      pid: 2
+      samples {
+        callstack_id: 1
+        self_allocated: 100
+        self_freed: 0
+        alloc_count: 1
+        free_count: 0
+      }
+      samples {
+        callstack_id: 2
+        self_allocated: 100
+        self_freed: 0
+        alloc_count: 1
+        free_count: 0
+      }
+    }
+  }
+}
+
+packet {
+  module_symbols {
+    path: "/liblib.so"
+    build_id: "build-id"
+    address_symbols {
+      address: 0x1000
+      lines {
+        function_name: "f1"
+        source_file_name: "f1.cc"
+        line_number: 1
+      }
+    }
+    address_symbols {
+      address: 0x2001
+      lines {
+        function_name: "f2"
+        source_file_name: "f2.cc"
+        line_number: 2
+      }
+    }
+    address_symbols {
+      address: 0x3000
+      lines {
+        function_name: "f3"
+        source_file_name: "f3.cc"
+        line_number: 33
+      }
+    }
+  }
+}
+
+packet {
+  module_disassembly {
+    path: "/liblib.so"
+    build_id: "build-id"
+    source_files: "f2.cc"
+    functions {
+      name: "f2"
+      start_address: 0x2000
+      size: 8
+      instructions {
+        address: 0x2000
+        bytes: "\x55"
+        text: "push rbp"
+        source_file_index: 0
+        line_number: 1
+      }
+      instructions {
+        address: 0x2001
+        bytes: "\xe8\x00\x00\x00\x00"
+        text: "call 0x3000"
+        target_address: 0x3000
+        target_symbol: "f3"
+        source_file_index: 0
+        line_number: 2
+      }
+      instructions {
+        address: 0x2006
+        bytes: "\xeb\xf8"
+        text: "jmp 0x2000"
+        target_address: 0x2000
+        source_file_index: 0
+        line_number: 3
+      }
+    }
+  }
+}
diff --git a/test/trace_processor/diff_tests/stdlib/callstacks/tests.py b/test/trace_processor/diff_tests/stdlib/callstacks/tests.py
new file mode 100644
index 0000000..8eaaa14
--- /dev/null
+++ b/test/trace_processor/diff_tests/stdlib/callstacks/tests.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+# 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.
+
+from python.generators.diff_tests.testing import Path
+from python.generators.diff_tests.testing import Csv
+from python.generators.diff_tests.testing import DiffTestBlueprint
+from python.generators.diff_tests.testing import TestSuite
+
+# One sample per heap profile allocation: stacks [f1, f2, f3] and [f1, f2].
+_SAMPLES = '(SELECT callsite_id FROM heap_profile_allocation WHERE count > 0)'
+
+
+class Callstacks(TestSuite):
+
+  def test_callsite_sample_counts(self):
+    return DiffTestBlueprint(
+        trace=Path('annotate.textproto'),
+        query=f"""
+        INCLUDE PERFETTO MODULE callstacks.annotate;
+
+        SELECT f.name, c.self_count, c.total_count
+        FROM _callsite_sample_counts!({_SAMPLES}) c
+        JOIN stack_profile_callsite sc ON sc.id = c.callsite_id
+        JOIN stack_profile_frame f ON f.id = sc.frame_id
+        ORDER BY f.name;
+        """,
+        out=Csv("""
+        "name","self_count","total_count"
+        "f1",0,2
+        "f2",1,2
+        "f3",1,1
+        """))
+
+  def test_sample_counts_by_address(self):
+    return DiffTestBlueprint(
+        trace=Path('annotate.textproto'),
+        query=f"""
+        INCLUDE PERFETTO MODULE callstacks.annotate;
+
+        SELECT mapping_name, build_id, rel_pc, self_count, total_count
+        FROM _sample_counts_by_address!({_SAMPLES})
+        ORDER BY rel_pc;
+        """,
+        out=Csv("""
+        "mapping_name","build_id","rel_pc","self_count","total_count"
+        "/liblib.so","6275696c642d6964",4096,0,2
+        "/liblib.so","6275696c642d6964",8193,1,2
+        "/liblib.so","6275696c642d6964",12288,1,1
+        """))
+
+  def test_sample_counts_by_source_line(self):
+    return DiffTestBlueprint(
+        trace=Path('annotate.textproto'),
+        query=f"""
+        INCLUDE PERFETTO MODULE callstacks.annotate;
+
+        SELECT source_file, line_number, self_count, total_count
+        FROM _sample_counts_by_source_line!({_SAMPLES})
+        ORDER BY source_file;
+        """,
+        out=Csv("""
+        "source_file","line_number","self_count","total_count"
+        "f1.cc",1,0,2
+        "f2.cc",2,1,2
+        "f3.cc",33,1,1
+        """))
+
+  def test_disassembly_function_for_address(self):
+    return DiffTestBlueprint(
+        trace=Path('annotate.textproto'),
+        query="""
+        INCLUDE PERFETTO MODULE callstacks.annotate;
+
+        SELECT
+          f.rel_pc,
+          (
+            SELECT name FROM disassembly_function
+            WHERE id = _disassembly_function_for_address(f.mapping, f.rel_pc)
+          ) AS function_name
+        FROM stack_profile_frame f
+        ORDER BY f.rel_pc;
+        """,
+        out=Csv("""
+        "rel_pc","function_name"
+        4096,"[NULL]"
+        8193,"f2"
+        12288,"[NULL]"
+        """))
+
+  def test_annotated_disassembly(self):
+    return DiffTestBlueprint(
+        trace=Path('annotate.textproto'),
+        query=f"""
+        INCLUDE PERFETTO MODULE callstacks.annotate;
+
+        SELECT
+          rel_pc, text, target_rel_pc, target_symbol, source_file,
+          line_number, self_count, total_count
+        FROM _annotated_disassembly!(
+          {_SAMPLES},
+          (SELECT id FROM disassembly_function WHERE name = 'f2')
+        );
+        """,
+        out=Csv("""
+        "rel_pc","text","target_rel_pc","target_symbol","source_file","line_number","self_count","total_count"
+        8192,"push rbp","[NULL]","[NULL]","f2.cc",1,0,0
+        8193,"call 0x3000",12288,"f3","f2.cc",2,1,2
+        8198,"jmp 0x2000",8192,"[NULL]","f2.cc",3,0,0
+        """))
+
+  def test_callstack_forest_rel_pc(self):
+    return DiffTestBlueprint(
+        trace=Path('annotate.textproto'),
+        query=f"""
+        INCLUDE PERFETTO MODULE callstacks.stack_profile;
+
+        SELECT name, mapping_name, rel_pc, self_count
+        FROM _callstacks_for_callsites!({_SAMPLES})
+        ORDER BY rel_pc;
+        """,
+        out=Csv("""
+        "name","mapping_name","rel_pc","self_count"
+        "f1","/liblib.so",4096,0
+        "f2","/liblib.so",8193,1
+        "f3","/liblib.so",12288,1
+        """))